Files
AriWeb-Suite/hal/ai/routes.mjs
T

52 lines
6.5 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 } from '../../lib/http.mjs';
import * as C from './connections.mjs';
import * as R from './rooms.mjs';
import { publicTools, SKILL_DOC } from './tools.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, authKinds: v.authKinds, tools: !!v.tools })) }));
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); return { ok: true, connection: C.create(d, b, ctx.operator.id) }; }));
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) throw new HttpError(400, 'oauth_denied', `공급자가 로그인을 거부했습니다: ${String(ctx.query.error).slice(0, 100)}`); const c = await C.oauthCallback(d, { code: ctx.query.code, state: ctx.query.state, operatorId: ctx.operator.id }); return ctx.redirect(`/ops/ai?oauth=ok&connection=${encodeURIComponent(c.id)}`); }));
// ---------------- 방·메시지
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() }));
app.get('/ops/api/ai/skill', () => ({ ok: true, skill: SKILL_DOC }));
}
// providers 모듈을 여기서 직접 import 하지 않고 connections 를 통해 노출(순환 없음)
import { PROVIDERS, discoverClis, CLI_TOOLS } from './providers.mjs';
const C_PROVIDERS = () => PROVIDERS;