AI관리 시스템 교체: ai-assistant 제거 + 멀티 에이전트 오케스트레이션 이식
Build & Deploy to K8s / build-and-deploy (push) Failing after 7m14s
Build & Deploy to K8s / build-and-deploy (push) Failing after 7m14s
Epic A: ai-assistant 디렉토리/Spring 프록시/프론트엔드 메뉴 완전 제거 Epic B: Flyway 도입 + 13 신규 테이블 마이그레이션 Epic C: 9 서비스 + 7 컨트롤러 + LlmClient 추상화 (Java 21/Spring/MyBatis) Epic D: ApiKey 인증 필터 (sk-pipe-* 키 SHA-256 검증) Epic E: OpenClaw 외부 엔진 docker-compose 통합 Epic F: Next.js 7 페이지 + lib/api/aiAgent.ts 이식 Epic G: 화면 그룹/메뉴 등록 마이그레이션 (V014) Epic H: 통합 빌드 검증 - DB: invyone PostgreSQL에 ai_agents/ai_agent_groups/... 13 테이블 + Quartz - 멀티테넌시: 모든 테이블에 company_code 강제 필터 - LLM: Anthropic/OpenAI/Google/Ollama 직접 클라이언트 (Spring AI 미도입) - 스케줄러: Quartz JDBC JobStore (cron 기반) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
const BASE = "/ai-agents";
|
||||
|
||||
export const aiAgentApi = {
|
||||
// 에이전트 CRUD
|
||||
list: (params?: { status?: string; search?: string }) =>
|
||||
apiClient.get(BASE, { params }).then((r) => r.data),
|
||||
getById: (id: number) =>
|
||||
apiClient.get(`${BASE}/${id}`).then((r) => r.data),
|
||||
create: (data: any) =>
|
||||
apiClient.post(BASE, data).then((r) => r.data),
|
||||
update: (id: number, data: any) =>
|
||||
apiClient.put(`${BASE}/${id}`, data).then((r) => r.data),
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`${BASE}/${id}`).then((r) => r.data),
|
||||
|
||||
// API 키 관리
|
||||
listKeys: () =>
|
||||
apiClient.get(`${BASE}/keys/list`).then((r) => r.data),
|
||||
createKey: (data: { name: string; agent_id?: number; rate_limit?: number }) =>
|
||||
apiClient.post(`${BASE}/keys`, data).then((r) => r.data),
|
||||
revokeKey: (id: number) =>
|
||||
apiClient.delete(`${BASE}/keys/${id}`).then((r) => r.data),
|
||||
|
||||
// LLM 프로바이더
|
||||
listProviders: () =>
|
||||
apiClient.get(`${BASE}/providers/list`).then((r) => r.data),
|
||||
createProvider: (data: any) =>
|
||||
apiClient.post(`${BASE}/providers`, data).then((r) => r.data),
|
||||
updateProvider: (id: number, data: any) =>
|
||||
apiClient.put(`${BASE}/providers/${id}`, data).then((r) => r.data),
|
||||
deleteProvider: (id: number) =>
|
||||
apiClient.delete(`${BASE}/providers/${id}`).then((r) => r.data),
|
||||
|
||||
// 대화 모니터링
|
||||
listConversations: (params?: { page?: number; limit?: number; agent_id?: number }) =>
|
||||
apiClient.get(`${BASE}/conversations/list`, { params }).then((r) => r.data),
|
||||
getConversation: (id: number) =>
|
||||
apiClient.get(`${BASE}/conversations/${id}`).then((r) => r.data),
|
||||
|
||||
// 사용량
|
||||
usageSummary: () =>
|
||||
apiClient.get(`${BASE}/usage/summary`).then((r) => r.data),
|
||||
usageLogs: (params?: { page?: number; limit?: number }) =>
|
||||
apiClient.get(`${BASE}/usage/logs`, { params }).then((r) => r.data),
|
||||
usageDaily: (days?: number) =>
|
||||
apiClient.get(`${BASE}/usage/daily`, { params: { days } }).then((r) => r.data),
|
||||
|
||||
// 멀티 에이전트 그룹
|
||||
listGroups: () =>
|
||||
apiClient.get("/ai-agent-groups").then((r) => r.data),
|
||||
getGroup: (id: number) =>
|
||||
apiClient.get(`/ai-agent-groups/${id}`).then((r) => r.data),
|
||||
createGroup: (data: { name: string; description?: string }) =>
|
||||
apiClient.post("/ai-agent-groups", data).then((r) => r.data),
|
||||
updateGroup: (id: number, data: any) =>
|
||||
apiClient.put(`/ai-agent-groups/${id}`, data).then((r) => r.data),
|
||||
deleteGroup: (id: number) =>
|
||||
apiClient.delete(`/ai-agent-groups/${id}`).then((r) => r.data),
|
||||
addGroupMember: (groupId: number, data: any) =>
|
||||
apiClient.post(`/ai-agent-groups/${groupId}/members`, data).then((r) => r.data),
|
||||
updateGroupMember: (memberId: number, data: any) =>
|
||||
apiClient.put(`/ai-agent-groups/members/${memberId}`, data).then((r) => r.data),
|
||||
removeGroupMember: (memberId: number) =>
|
||||
apiClient.delete(`/ai-agent-groups/members/${memberId}`).then((r) => r.data),
|
||||
getAvailableConnectors: () =>
|
||||
apiClient.get("/ai-agent-groups/connectors").then((r) => r.data),
|
||||
|
||||
// 지식 파일 라이브러리
|
||||
listKnowledge: (params?: { category?: string; search?: string }) =>
|
||||
apiClient.get("/ai-knowledge", { params }).then((r) => r.data),
|
||||
getKnowledge: (id: number) =>
|
||||
apiClient.get(`/ai-knowledge/${id}`).then((r) => r.data),
|
||||
createKnowledge: (data: { name: string; file_name: string; category: string; description?: string; content: string }) =>
|
||||
apiClient.post("/ai-knowledge", data).then((r) => r.data),
|
||||
updateKnowledge: (id: number, data: any) =>
|
||||
apiClient.put(`/ai-knowledge/${id}`, data).then((r) => r.data),
|
||||
deleteKnowledge: (id: number) =>
|
||||
apiClient.delete(`/ai-knowledge/${id}`).then((r) => r.data),
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* AI 어시스턴트 전용 API 클라이언트
|
||||
* - INVION와 같은 서비스/같은 포트: /api/ai/v1 로 호출 (Next → backend-node → AI 서비스 프록시)
|
||||
* - 인증 토큰은 sessionStorage 'ai-assistant-auth' 사용 (INVION 인증과 분리)
|
||||
*/
|
||||
import axios, { AxiosError } from "axios";
|
||||
import type { AiAssistantAuthState } from "./types";
|
||||
|
||||
const STORAGE_KEY = "ai-assistant-auth";
|
||||
|
||||
/** 같은 오리진 기준 AI API prefix (backend-node가 /api/ai/v1 을 AI 서비스로 프록시) */
|
||||
function getBaseUrl(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return "/api/ai/v1";
|
||||
}
|
||||
|
||||
export function getAiAssistantAuth(): AiAssistantAuthState | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as AiAssistantAuthState) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAiAssistantAuth(state: AiAssistantAuthState | null): void {
|
||||
if (typeof window === "undefined") return;
|
||||
if (state) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
else sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getAiAssistantAccessToken(): string | null {
|
||||
return getAiAssistantAuth()?.accessToken ?? null;
|
||||
}
|
||||
|
||||
let refreshing = false;
|
||||
const queue: Array<{ resolve: (t: string) => void; reject: (e: unknown) => void }> = [];
|
||||
|
||||
function processQueue(error: unknown, token: string | null) {
|
||||
queue.forEach((p) => (error ? p.reject(error) : p.resolve(token!)));
|
||||
queue.length = 0;
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: "",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const base = getBaseUrl();
|
||||
if (base) config.baseURL = base;
|
||||
const token = getAiAssistantAccessToken();
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (err: AxiosError) => {
|
||||
const original = err.config as typeof err.config & { _retry?: boolean };
|
||||
const isAuth = original?.url?.includes("/auth/");
|
||||
if (isAuth || err.response?.status !== 401 || original?._retry) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
if (refreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
queue.push({ resolve, reject });
|
||||
})
|
||||
.then((token) => {
|
||||
if (original.headers) original.headers.Authorization = `Bearer ${token}`;
|
||||
return client(original);
|
||||
})
|
||||
.catch((e) => Promise.reject(e));
|
||||
}
|
||||
|
||||
original._retry = true;
|
||||
refreshing = true;
|
||||
const auth = getAiAssistantAuth();
|
||||
try {
|
||||
if (!auth?.refreshToken) throw new Error("No refresh token");
|
||||
const base = getBaseUrl();
|
||||
const { data } = await axios.post<{ data: { accessToken: string } }>(
|
||||
`${base}/auth/refresh`,
|
||||
{ refreshToken: auth.refreshToken },
|
||||
{ headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const newToken = data?.data?.accessToken;
|
||||
if (!newToken) throw new Error("No access token");
|
||||
const newState: AiAssistantAuthState = {
|
||||
...auth,
|
||||
accessToken: newToken,
|
||||
};
|
||||
setAiAssistantAuth(newState);
|
||||
processQueue(null, newToken);
|
||||
if (original.headers) original.headers.Authorization = `Bearer ${newToken}`;
|
||||
return client(original);
|
||||
} catch (e) {
|
||||
processQueue(e, null);
|
||||
setAiAssistantAuth(null);
|
||||
return Promise.reject(err);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** AI 어시스턴트 로그인 (이메일/비밀번호) */
|
||||
export async function loginAiAssistant(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AiAssistantAuthState> {
|
||||
const base = getBaseUrl();
|
||||
const { data } = await axios.post<{
|
||||
data: { user: AiAssistantAuthState["user"]; accessToken: string; refreshToken: string };
|
||||
}>(`${base}/auth/login`, { email, password }, { headers: { "Content-Type": "application/json" }, withCredentials: true });
|
||||
const state: AiAssistantAuthState = {
|
||||
user: data.data.user,
|
||||
accessToken: data.data.accessToken,
|
||||
refreshToken: data.data.refreshToken,
|
||||
};
|
||||
setAiAssistantAuth(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export default client;
|
||||
@@ -1,8 +0,0 @@
|
||||
export { default as aiAssistantApi } from "./client";
|
||||
export {
|
||||
getAiAssistantAuth,
|
||||
setAiAssistantAuth,
|
||||
getAiAssistantAccessToken,
|
||||
loginAiAssistant,
|
||||
} from "./client";
|
||||
export * from "./types";
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* AI 어시스턴트 API 응답/요청 타입 (workspace_assistant 백엔드 연동)
|
||||
*/
|
||||
|
||||
export interface AiAssistantUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "user" | "admin";
|
||||
status?: string;
|
||||
plan?: string;
|
||||
monthlyTokenLimit?: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantAuthState {
|
||||
user: AiAssistantUser;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
usage?: {
|
||||
today?: { tokens?: number; requests?: number };
|
||||
monthly?: { totalTokens?: number; totalCost?: number };
|
||||
};
|
||||
limit?: { monthly?: number };
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyItem {
|
||||
id: number;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
status: string;
|
||||
usageCount?: number;
|
||||
lastUsedAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UsageLogItem {
|
||||
id: number;
|
||||
success: boolean;
|
||||
providerName?: string;
|
||||
modelName?: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
costUsd?: number;
|
||||
responseTimeMs?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
users?: { total?: number; active?: number };
|
||||
apiKeys?: { total?: number };
|
||||
providers?: { active?: number };
|
||||
}
|
||||
|
||||
export interface LlmProvider {
|
||||
id: number;
|
||||
displayName: string;
|
||||
modelName: string;
|
||||
apiKey?: string;
|
||||
priority: number;
|
||||
maxTokens?: number;
|
||||
temperature?: number;
|
||||
isActive: boolean;
|
||||
costPer1kInputTokens?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user