70 lines
11 KiB
JavaScript
70 lines
11 KiB
JavaScript
/**
|
|
* hal/ai/routes.mjs — AI 채팅룸 운영자 API(0.3.0). hal/app.mjs 의 운영자 미들웨어(네트워크 관문 → 운영자 세션 → CSRF) 뒤에서만 불린다.
|
|
*
|
|
* 연결 GET/POST /ops/api/ai/connections · PATCH/DELETE /ops/api/ai/connections/:id · POST …/:id/secret · POST …/:id/test · POST …/:id/oauth/start · GET /ops/ai/oauth/callback
|
|
* 방 GET /ops/api/ai/rooms · PATCH /ops/api/ai/rooms/:id · GET/POST /ops/api/ai/rooms/:id/messages · POST /ops/api/ai/rooms/:id/reset
|
|
* 제안 GET /ops/api/ai/actions · POST /ops/api/ai/actions/:id/approve · POST /ops/api/ai/actions/:id/reject
|
|
* 도구 GET /ops/api/ai/tools · GET /ops/api/ai/skill · GET /ops/api/ai/cli(0.3.1, 서버에 설치된 CLI 탐색)
|
|
*
|
|
* 보안 경계(기존 규칙을 그대로 적용, 약화 없음)
|
|
* - 인증 키 변경(API 키 등록·교체, OAuth client secret, 연결 삭제) → requireRecent(최근 10분 재인증).
|
|
* - 제안 승인: write → requireRecent, high → requireRecent + threeStep(대상 식별정보 = 도구가 정한 identifiers). 거부는 재인증 없이 가능(실행하지 않는 쪽).
|
|
* - OAuth 콜백은 /ops/api 밖(GET, 브라우저 리다이렉트)이지만 운영자 세션이 없으면 401, state 가 시작 운영자와 다르면 거부.
|
|
*/
|
|
import { HttpError, esc } from '../../lib/http.mjs';
|
|
import * as C from './connections.mjs';
|
|
import * as R from './rooms.mjs';
|
|
import { publicTools, SKILL_DOC, TOOL_PRESETS } from './tools.mjs';
|
|
import { SHELL_COMMANDS, SHELL_GUIDE } from './shell.mjs';
|
|
import { publicCommands, helpText } from './commands.mjs';
|
|
|
|
export function registerAiRoutes(app, d, { requireRecent, threeStep, wrap }) {
|
|
const ai = d.ai; const op = (ctx) => ({ id: ctx.operator.id, login: ctx.operator.login, displayName: ctx.operator.displayName });
|
|
// ---------------- 연결
|
|
app.get('/ops/api/ai/connections', () => ({ ok: true, connections: C.list(d), providers: Object.entries(C_PROVIDERS()).map(([k, v]) => ({ id: k, label: v.label, kind: v.kind, authKinds: v.authKinds, tools: !!v.tools, defaultBase: v.defaultBase || null, guide: v.guide || null, oauthPreset: v.oauthPreset ? { mode: v.oauthPreset.mode || 'authorization_code', needsClient: v.oauthPreset.mode !== 'openrouter_pkce' } : null })), callbackUrl: C.redirectUri(d) }));
|
|
app.post('/ops/api/ai/connections', wrap(async (ctx) => { const b = await ctx.body(); if (b.authKind === 'api_key' || (b.oauth && b.oauth.clientSecret)) requireRecent(ctx); const c = C.create(d, b, ctx.operator.id);
|
|
// [0.3.2] 다음 단계 안내: OAuth 는 바로 로그인 창을 열고, 로컬 CLI 는 확인(탐색·로그인 상태)으로 잇는다 — 화면이 자동으로 진행한다
|
|
const next = c.authKind === 'oauth' ? { action: 'oauth', startPath: `/ops/api/ai/connections/${c.id}/oauth/start`, note: '저장했습니다. 공급자 로그인 창을 엽니다 — 승인하면 토큰이 Vault 에 저장됩니다.' } : c.provider === 'local-cli' ? { action: 'cli_check', testPath: `/ops/api/ai/connections/${c.id}/test`, note: 'CLI 를 탐색해 로그인 상태를 확인합니다. 로그인이 안 되어 있으면 텍스트 셸을 엽니다.' } : { action: 'test', testPath: `/ops/api/ai/connections/${c.id}/test`, note: '모델 목록을 받아 연결을 확인합니다.' };
|
|
return { ok: true, connection: c, next }; }));
|
|
app.patch('/ops/api/ai/connections/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.oauth && b.oauth.clientSecret) requireRecent(ctx); return { ok: true, connection: C.update(d, ctx.params.id, b, ctx.operator.id) }; }));
|
|
app.delete('/ops/api/ai/connections/:id', wrap((ctx) => { requireRecent(ctx); return { ok: true, ...C.remove(d, ctx.params.id, ctx.operator.id) }; }));
|
|
app.post('/ops/api/ai/connections/:id/secret', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, connection: C.setSecret(d, ctx.params.id, b.apiKey, ctx.operator.id) }; }));
|
|
app.post('/ops/api/ai/connections/:id/test', wrap(async (ctx) => ({ ok: true, ...(await C.testConnection(d, ctx.params.id, ctx.operator.id)) })));
|
|
app.get('/ops/api/ai/cli', wrap(async () => ({ ok: true, ...(await discoverClis()), tools: Object.entries(CLI_TOOLS).map(([k, v]) => ({ tool: k, label: v.label, loginCommand: v.loginCommand })) }))); // [0.3.1] 서버에 설치된 CLI 자동 탐색(버전·로그인·기본 모델)
|
|
app.post('/ops/api/ai/connections/:id/oauth/start', wrap((ctx) => ({ ok: true, ...C.oauthStart(d, ctx.params.id, { operatorId: ctx.operator.id }) })));
|
|
app.get('/ops/ai/oauth/callback', wrap(async (ctx) => { if (!ctx.operator) throw new HttpError(401, 'unauthenticated', '운영자 로그인이 필요합니다'); if (ctx.query.error) return ctx.redirect(`/ops/ai/oauth/done?ok=0&reason=${encodeURIComponent(String(ctx.query.error).slice(0, 60))}`); const c = await C.oauthCallback(d, { code: ctx.query.code, state: ctx.query.state, operatorId: ctx.operator.id }); return ctx.redirect(`/ops/ai/oauth/done?ok=1&connection=${encodeURIComponent(c.id)}`); }));
|
|
/** [0.3.2] OAuth 완료 페이지: 팝업으로 열렸으면 부모 창(AI 연동 화면)에 알리고 스스로 닫힌다. 비밀 없음(결과·연결 id 만). 외부 스크립트 없음. */
|
|
app.get('/ops/ai/oauth/done', (ctx) => ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AI 연동 OAuth · Ariweb HAL</title><link rel="stylesheet" href="/ops/ui/ops.css"></head><body class="login"><main class="login-box"><h1>${ctx.query.ok === '1' ? 'OAuth 로그인 완료' : 'OAuth 로그인 실패'}</h1><p class="muted">${ctx.query.ok === '1' ? '토큰을 HAL Vault 에 저장했습니다. 이 창은 곧 닫힙니다.' : `공급자가 로그인을 거부했거나 교환에 실패했습니다(${esc(String(ctx.query.reason || ''))}).`}</p><p><a href="/ops/ai?oauth=${ctx.query.ok === '1' ? 'ok' : 'fail'}">AI 연동 화면으로</a></p></main>
|
|
<script>(function(){var p=new URLSearchParams(location.search);var msg={type:'hal-oauth',ok:p.get('ok')==='1',connection:p.get('connection')||null,reason:p.get('reason')||null};var target=msg.ok?'/ops/ai?oauth=ok':'/ops/ai?oauth=fail';if(msg.connection)target+='&connection='+encodeURIComponent(msg.connection);
|
|
if(window.opener&&!window.opener.closed){try{window.opener.postMessage(msg,location.origin);}catch(e){}window.close();setTimeout(function(){location.href=target;},1200);}else{location.href=target;}})();</script></body></html>`));
|
|
// ---------------- 방·메시지
|
|
app.get('/ops/api/ai/rooms', () => ({ ok: true, rooms: R.listRooms(d, C.list(d)) }));
|
|
app.patch('/ops/api/ai/rooms/:id', wrap(async (ctx) => { const b = await ctx.body(); const r = R.renameRoom(d, ctx.params.id, b.title); return { ok: true, room: { id: r.id, kind: r.kind, title: r.title, connectionId: r.connection_id } }; }));
|
|
app.get('/ops/api/ai/rooms/:id/messages', wrap((ctx) => { const room = R.getRoom(d, ctx.params.id); if (!room) throw new HttpError(404, 'room_not_found', '방이 없습니다'); return { ok: true, messages: R.listMessages(d, room.id, { after: ctx.query.after, limit: Number(ctx.query.limit || 500) }), pending: ai.pending(room.id), actions: ai.listActions({ roomId: room.id, limit: 100 }), serverTime: new Date().toISOString() }; }));
|
|
app.post('/ops/api/ai/rooms/:id/messages', wrap(async (ctx) => { const b = await ctx.body(); const r = ai.send(ctx.params.id, { text: b.text, operator: op(ctx), context: b.context && typeof b.context === 'object' ? { page: String(b.context.page || '').slice(0, 60), id: String(b.context.id || '').slice(0, 120) } : null }); return { ok: true, ...r }; }));
|
|
app.post('/ops/api/ai/rooms/:id/reset', wrap((ctx) => { const room = R.getRoom(d, ctx.params.id); if (!room) throw new HttpError(404, 'room_not_found', '방이 없습니다'); R.resetRoom(d, room.id); return { ok: true }; }));
|
|
// ---------------- 제안(승인·거부)
|
|
app.get('/ops/api/ai/actions', (ctx) => ({ ok: true, actions: ai.listActions({ status: ctx.query.status || null, roomId: ctx.query.roomId || null, limit: Number(ctx.query.limit || 200) }) }));
|
|
app.post('/ops/api/ai/actions/:id/approve', wrap(async (ctx) => {
|
|
const a = ai.getAction(ctx.params.id); if (!a) throw new HttpError(404, 'action_not_found', '제안이 없습니다'); if (a.status !== 'proposed') throw new HttpError(409, 'action_not_proposed', `이미 처리된 제안입니다(${a.status})`);
|
|
requireRecent(ctx); const b = await ctx.body(); if (a.risk === 'high') threeStep(b, a.identifiers); // 기존 고위험 규칙 그대로: 재인증 + 3단계 확인
|
|
return { ok: true, action: await ai.execute(a.id, op(ctx)) };
|
|
}));
|
|
app.post('/ops/api/ai/actions/:id/reject', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, action: ai.reject(ctx.params.id, op(ctx), b.reason || '') }; }));
|
|
// ---------------- 도구·스킬
|
|
app.get('/ops/api/ai/tools', () => ({ ok: true, tools: publicTools(), presets: TOOL_PRESETS.map((p) => ({ id: p.id, label: p.label, count: p.tools.length })) }));
|
|
app.get('/ops/api/ai/mcp-config', () => ({ ok: true, ...mcpConfig(d) })); // [0.3.2] 외부 MCP 클라이언트용 설정 스니펫(비밀 없음)
|
|
// ---------------- [0.3.2] 텍스트 셸(CLI 로그인): 허용 명령만, 시작은 재인증 필요, 입력은 저장하지 않음
|
|
app.get('/ops/api/ai/shell', (ctx) => ({ ok: true, sessions: d.shell.list(), commands: SHELL_COMMANDS, guide: SHELL_GUIDE }));
|
|
app.post('/ops/api/ai/shell', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, ...d.shell.start({ tool: String(b.tool || ''), command: String(b.command || ''), operator: ctx.operator.id, cols: b.cols, rows: b.rows }) }; }));
|
|
app.post('/ops/api/ai/shell/kill-all', wrap((ctx) => ({ ok: true, sessions: d.shell.killAll(ctx.operator.id) }))); // [0.3.9] 한도 오류 복구
|
|
app.get('/ops/api/ai/commands', () => ({ ok: true, commands: publicCommands(), help: helpText() })); // [0.3.9] 슬래시 명령 목록
|
|
app.get('/ops/api/ai/shell/:id', wrap((ctx) => ({ ok: true, ...d.shell.get(ctx.params.id, { after: ctx.query.after }) })));
|
|
app.post('/ops/api/ai/shell/:id/input', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, session: d.shell.input(ctx.params.id, b.data) }; }));
|
|
app.delete('/ops/api/ai/shell/:id', wrap((ctx) => ({ ok: true, session: d.shell.kill(ctx.params.id) })));
|
|
app.get('/ops/api/ai/skill', () => ({ ok: true, skill: SKILL_DOC }));
|
|
}
|
|
// providers 모듈을 여기서 직접 import 하지 않고 connections 를 통해 노출(순환 없음)
|
|
import { PROVIDERS, discoverClis, CLI_TOOLS, mcpConfig } from './providers.mjs';
|
|
const C_PROVIDERS = () => PROVIDERS;
|