@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.
- package/.next/standalone/apps/web/.next/BUILD_ID +1 -1
- package/.next/standalone/apps/web/.next/build-manifest.json +5 -5
- package/.next/standalone/apps/web/.next/prerender-manifest.json +3 -3
- package/.next/standalone/apps/web/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/.next/standalone/apps/web/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/page/build-manifest.json +2 -2
- package/.next/standalone/apps/web/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings/page/build-manifest.json +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.html +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_full.segment.rsc +3 -3
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_head.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_index.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/apps/web/.next/server/app/settings.segments/settings.segment.rsc +1 -1
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page/build-manifest.json +2 -2
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page.js.nft.json +1 -1
- package/.next/standalone/apps/web/.next/server/app/tasks/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0nhzci6._.js +4 -8
- package/.next/standalone/apps/web/.next/server/chunks/ssr/[root-of-the-server]__0vi01rl._.js +4 -8
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{[root-of-the-server]__04g~n~m._.js → [root-of-the-server]__0zf09ry._.js} +2 -2
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_04l3mh~._.js +10 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0bt-4.4._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0e3431w._.js +10 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0g.cd6a._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0lirkqp._.js +10 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0soxqr.._.js +3 -0
- package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_settings_page_tsx_0i6itlb._.js +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/apps_web_src_app_tasks_[id]_page_tsx_0jshsm2._.js +4 -5
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0h0imx2._.js → node_modules_0_4__2k._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0pbr55t._.js → node_modules_0bqsyfk._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_06vr-0a._.js → node_modules_0bzw~9d._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0k7u4.c._.js → node_modules_0fsewd2._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0c6z_cw._.js → node_modules_0l~40og._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0jo-w3s._.js → node_modules_0pk6e0.._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0zkg-x0._.js → node_modules_0smdx~2._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/{node_modules_0fcqekz._.js → node_modules_0uej915._.js} +1 -1
- package/.next/standalone/apps/web/.next/server/middleware-build-manifest.js +5 -5
- package/.next/standalone/apps/web/.next/server/pages/404.html +1 -1
- package/.next/standalone/apps/web/.next/server/pages/500.html +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/apps/web/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/{0p.4ej_8gk7m7.js → 02.u6zk77a338.js} +10 -11
- package/.next/standalone/apps/web/.next/static/chunks/05n6~h_wwg.jj.js +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/0_ygv4z~5c_9z.js +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/0g5hoj5w-z.~6.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/0nhcl~svhi_26.js +7 -0
- package/.next/standalone/apps/web/.next/static/chunks/0p4dfdgz~3qt3.js +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/0p9~lk406udl5.css +4 -0
- package/.next/standalone/apps/web/.next/static/chunks/0q-u7bl7oa42n.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/0q_j4~6_etj_q.js +7 -0
- package/.next/standalone/apps/web/.next/static/chunks/0zjymvayyxt-d.js +5 -0
- package/.next/standalone/apps/web/.next/static/chunks/{0lp.2eizum8y~.js → 11rs2-0jl1rzr.js} +1 -1
- package/.next/standalone/apps/web/.next/static/chunks/{15~pc9ql1ab38.js → 158q94mo2-6sx.js} +7 -7
- package/.next/standalone/apps/web/.next/static/chunks/167xa3jy_0g.u.js +1 -0
- package/.next/standalone/apps/web/.next/static/chunks/{turbopack-0079niub0zo4u.js → turbopack-07pr2uza0.4cp.js} +1 -1
- package/dist/server/basic-auth.js +9 -1
- package/dist/server/generated/prisma/internal/class.js +1 -1
- package/dist/server/index.js +20 -0
- package/dist/server/lib/claude-command.d.ts +6 -0
- package/dist/server/lib/claude-command.js +85 -0
- package/dist/server/lib/claude-config-guard.d.ts +12 -0
- package/dist/server/lib/claude-config-guard.js +49 -0
- package/dist/server/lib/lmstudio.d.ts +24 -0
- package/dist/server/lib/lmstudio.js +198 -0
- package/dist/server/lib/local-llm-env.d.ts +28 -0
- package/dist/server/lib/local-llm-env.js +91 -0
- package/dist/server/lib/local-llm.d.ts +56 -0
- package/dist/server/lib/local-llm.js +366 -0
- package/dist/server/lib/ollama-settings.d.ts +16 -10
- package/dist/server/lib/ollama-settings.js +104 -91
- package/dist/server/lib/searxng.d.ts +26 -0
- package/dist/server/lib/searxng.js +308 -0
- package/dist/server/routes/local-llm.d.ts +2 -0
- package/dist/server/routes/local-llm.js +192 -0
- package/dist/server/routes/mcp-web.d.ts +2 -0
- package/dist/server/routes/mcp-web.js +107 -0
- package/dist/server/routes/onboarding.js +4 -4
- package/dist/server/routes/settings.js +100 -26
- package/dist/server/routes/tasks.js +5 -1
- package/dist/server/routes/terminal.js +53 -11
- package/npm-shrinkwrap.json +5 -5
- package/package.json +1 -1
- package/prisma/schema.prisma +1 -1
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_016-lxb._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0hh~0f6._.js +0 -3
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0r0xwce._.js +0 -6
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_0sbp1da._.js +0 -6
- package/.next/standalone/apps/web/.next/server/chunks/ssr/_12c1w~~._.js +0 -6
- package/.next/standalone/apps/web/.next/static/chunks/0229rjr88pv9k.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/04l9g6rg7~-d5.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0citvl2medd0s.js +0 -11
- package/.next/standalone/apps/web/.next/static/chunks/0e8u7rqrqfiiy.js +0 -1
- package/.next/standalone/apps/web/.next/static/chunks/0pg---_6a_4ls.css +0 -4
- package/.next/standalone/apps/web/.next/static/chunks/0uv~zl3--e3wo.js +0 -11
- package/.next/standalone/apps/web/.next/static/chunks/11rpr3..poyv8.js +0 -5
- package/.next/standalone/apps/web/.next/static/chunks/1200zkjn5n2w7.js +0 -1
- /package/.next/standalone/apps/web/.next/static/{TJDp2MIqJ14hotMmE5sIo → C1cjF-gU-snY_atxJbn1v}/_buildManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{TJDp2MIqJ14hotMmE5sIo → C1cjF-gU-snY_atxJbn1v}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/apps/web/.next/static/{TJDp2MIqJ14hotMmE5sIo → 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,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 {
|
|
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
|
-
//
|
|
10
|
-
const
|
|
9
|
+
// 実験的機能のローカルLLM(Ollama / LM Studio)を設定済みなら、Anthropic のトークンがなくても claude を起動できる
|
|
10
|
+
const localLlmReady = await isLocalLlmReady();
|
|
11
11
|
return reply.status(200).send({
|
|
12
|
-
data: { completed: hasGlobalToken ||
|
|
12
|
+
data: { completed: hasGlobalToken || localLlmReady, hasGlobalToken, localLlmReady },
|
|
13
13
|
});
|
|
14
14
|
}
|
|
15
15
|
catch (error) {
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
-
import { getOllamaAccount, getOllamaSettings,
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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/
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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' });
|
|
@@ -5,8 +5,10 @@ import { ptyManager } from '../pty-manager.js';
|
|
|
5
5
|
import { prisma } from '../lib/db.js';
|
|
6
6
|
import { getWorktreePath } from '../lib/worktree-manager.js';
|
|
7
7
|
import { getEnv } from '../lib/repositories/environment.js';
|
|
8
|
-
import { ensureClaudeOnboardingCompleted } from '../lib/claude-config-guard.js';
|
|
9
|
-
import {
|
|
8
|
+
import { ensureClaudeOnboardingCompleted, removeLocalModelFromUserSettings, } from '../lib/claude-config-guard.js';
|
|
9
|
+
import { LOCAL_LLM_UNSET_ENV_KEYS, LOCAL_MODEL_ALIAS, buildLocalLlmEnv, } from '../lib/local-llm-env.js';
|
|
10
|
+
import { ensureActiveLoaded, getLocalLlmSettings } from '../lib/local-llm.js';
|
|
11
|
+
import { LOCAL_LLM_PROXY_URL, buildClaudeCommand, isLocalLlmMode } from '../lib/claude-command.js';
|
|
10
12
|
// サーバーはプロジェクトルートから起動されるため process.cwd() でルートを取得
|
|
11
13
|
const TSUNAGI_EDITOR_PATH = path.resolve(process.cwd(), 'scripts/monaco-editor.sh');
|
|
12
14
|
// Fastify(API) の公開ポート。index.ts と同じ既定値・同じ環境変数(TSUNAGI_SERVER_PORT)を見る。
|
|
@@ -35,6 +37,22 @@ function stripTerminalQueries(s) {
|
|
|
35
37
|
.replace(/\x1b\[[?>]?[0-9;]*n/g, '') // DSR: ESC[?6n(主犯) / ESC[6n / ESC[5n など
|
|
36
38
|
.replace(/\x1b\[[?>=]?[0-9;]*c/g, ''); // DA: ESC[c / ESC[0c / ESC[>c / ESC[=c
|
|
37
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* ローカルLLMタブで注入する環境変数を返す。Claude Code は tsunagi の中継口に接続し、
|
|
42
|
+
* 中継口が使用中のモデルへ転送する。最初の発言を待たせないよう、読み込みはここで始めておく(完了は待たない)
|
|
43
|
+
*/
|
|
44
|
+
async function prepareLocalLlm() {
|
|
45
|
+
const { active, extraEnv } = await getLocalLlmSettings();
|
|
46
|
+
if (!active) {
|
|
47
|
+
throw new Error('ローカルLLMのモデルが未設定です。Settings で使用するモデルを選んでください');
|
|
48
|
+
}
|
|
49
|
+
ensureActiveLoaded().catch(() => undefined);
|
|
50
|
+
return buildLocalLlmEnv({
|
|
51
|
+
proxyUrl: LOCAL_LLM_PROXY_URL,
|
|
52
|
+
contextTokens: active.contextTokens,
|
|
53
|
+
extraEnv,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
38
56
|
export async function terminalRoutes(fastify) {
|
|
39
57
|
const io = fastify.io;
|
|
40
58
|
// socket.io イベントハンドラ
|
|
@@ -182,7 +200,8 @@ export async function terminalRoutes(fastify) {
|
|
|
182
200
|
});
|
|
183
201
|
// POST /terminal/sessions - セッション作成(または既存セッション再利用)
|
|
184
202
|
fastify.post('/terminal/sessions', async (request, reply) => {
|
|
185
|
-
const { cwd, env, sessionId: requestedSessionId,
|
|
203
|
+
const { cwd, env, sessionId: requestedSessionId, claude } = request.body ?? {};
|
|
204
|
+
let { command } = request.body ?? {};
|
|
186
205
|
const sessionId = requestedSessionId ?? crypto.randomUUID();
|
|
187
206
|
// 既存セッションが生きていれば再利用
|
|
188
207
|
const existing = ptyManager.getSession(sessionId);
|
|
@@ -226,21 +245,25 @@ export async function terminalRoutes(fastify) {
|
|
|
226
245
|
catch (err) {
|
|
227
246
|
fastify.log.warn({ err }, 'Failed to load env vars');
|
|
228
247
|
}
|
|
229
|
-
//
|
|
248
|
+
// ローカルLLMタブ: Anthropic の代わりに tsunagi の中継口(→ Ollama / LM Studio)で claude を動かす。
|
|
230
249
|
// DB の環境変数より後に適用し、Anthropic の認証トークンは環境変数ごと取り除く。
|
|
231
250
|
let providerEnv = {};
|
|
232
251
|
let unsetKeys = [];
|
|
233
|
-
if (tabMode
|
|
234
|
-
|
|
235
|
-
|
|
252
|
+
if (isLocalLlmMode(tabMode)) {
|
|
253
|
+
try {
|
|
254
|
+
providerEnv = await prepareLocalLlm();
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
236
257
|
return reply
|
|
237
258
|
.status(400)
|
|
238
|
-
.send({ error:
|
|
259
|
+
.send({ error: err instanceof Error ? err.message : String(err) });
|
|
239
260
|
}
|
|
240
|
-
|
|
241
|
-
|
|
261
|
+
unsetKeys = LOCAL_LLM_UNSET_ENV_KEYS;
|
|
262
|
+
}
|
|
263
|
+
if (claude) {
|
|
264
|
+
command = await buildClaudeCommand(sessionId, (tabMode ?? 'claude'));
|
|
242
265
|
}
|
|
243
|
-
// 優先順位: リクエストで渡されたenv >
|
|
266
|
+
// 優先順位: リクエストで渡されたenv > ローカルLLMのprovider env > DB env(global/owner/repo) > tsunagi独自のEDITOR設定
|
|
244
267
|
// tsunagi-editor.sh をデフォルトにすることで Ctrl+G が Monaco Modal を開く。
|
|
245
268
|
// DB / リクエストで EDITOR が明示設定されている場合はそちらが優先される。
|
|
246
269
|
const tsunagiDefaultEnv = {
|
|
@@ -259,6 +282,8 @@ export async function terminalRoutes(fastify) {
|
|
|
259
282
|
// いると、次回 claude 起動時にオンボーディングウィザードが表示され --resume/
|
|
260
283
|
// --session-id を前提にした自動起動フローが止まるため、起動前に補正する。
|
|
261
284
|
await ensureClaudeOnboardingCompleted();
|
|
285
|
+
// 監視の取りこぼしに備え、起動前にも中継口の名前が既定モデルに残っていないか補正する
|
|
286
|
+
await removeLocalModelFromUserSettings(LOCAL_MODEL_ALIAS);
|
|
262
287
|
// シェルの初期化(プロンプト表示)を待つため少し遅延させて書き込む
|
|
263
288
|
setTimeout(() => {
|
|
264
289
|
session.pty.write(cmd);
|
|
@@ -271,6 +296,23 @@ export async function terminalRoutes(fastify) {
|
|
|
271
296
|
return reply.status(500).send({ error: message });
|
|
272
297
|
}
|
|
273
298
|
});
|
|
299
|
+
// POST /terminal/sessions/:sessionId/claude - 既存 PTY で claude を(再)起動する
|
|
300
|
+
// コマンドはタブの mode に応じてサーバーで組み立てる(ローカルLLMタブは引数が付くため)
|
|
301
|
+
fastify.post('/terminal/sessions/:sessionId/claude', async (request, reply) => {
|
|
302
|
+
const { sessionId } = request.params;
|
|
303
|
+
const session = ptyManager.getSession(sessionId);
|
|
304
|
+
if (!session) {
|
|
305
|
+
return reply.status(404).send({ error: 'Session not found' });
|
|
306
|
+
}
|
|
307
|
+
const tab = await prisma.tab.findUnique({ where: { tabId: sessionId } }).catch(() => null);
|
|
308
|
+
const mode = (tab?.mode ?? 'claude');
|
|
309
|
+
if (isLocalLlmMode(mode)) {
|
|
310
|
+
// 最初の発言を待たせないよう読み込みを始めておく(未設定等のエラーは中継口がタブに返す)
|
|
311
|
+
ensureActiveLoaded().catch(() => undefined);
|
|
312
|
+
}
|
|
313
|
+
session.pty.write(`${await buildClaudeCommand(sessionId, mode)}\n`);
|
|
314
|
+
return reply.status(204).send();
|
|
315
|
+
});
|
|
274
316
|
// DELETE /terminal/sessions/:sessionId - セッション明示削除(PTY kill)
|
|
275
317
|
fastify.delete('/terminal/sessions/:sessionId', async (request, reply) => {
|
|
276
318
|
const { sessionId } = request.params;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minimalcorp/tsunagi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.39",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@minimalcorp/tsunagi",
|
|
9
|
-
"version": "0.1.
|
|
9
|
+
"version": "0.1.39",
|
|
10
10
|
"license": "SEE LICENSE IN LICENSE",
|
|
11
11
|
"os": [
|
|
12
12
|
"darwin",
|
|
@@ -234,9 +234,9 @@
|
|
|
234
234
|
}
|
|
235
235
|
},
|
|
236
236
|
"node_modules/@fastify/multipart": {
|
|
237
|
-
"version": "10.1.
|
|
238
|
-
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.1.
|
|
239
|
-
"integrity": "sha512-
|
|
237
|
+
"version": "10.1.2",
|
|
238
|
+
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-10.1.2.tgz",
|
|
239
|
+
"integrity": "sha512-WHsqW5ffDXCbXXtdBQDryxu58fQvFPVGh4E4MlCO6hwCLNm0lYTCS6GKXUw2P64REDoECUNpMVvtMMstdIyBjQ==",
|
|
240
240
|
"funding": [
|
|
241
241
|
{
|
|
242
242
|
"type": "github",
|
package/package.json
CHANGED