fix(ai-modules): JSONB ::text 응답 자동 파싱 + workspace 카드 깨짐 수정
백엔드:
- 6개 AI Service (group/apiKey/provider/conversation/agent/scheduler) 가 응답 메서드에서
`parseJsonField` 헬퍼로 JSONB(::text) 컬럼 (connectors / config / permissions /
metadata / tool_calls / notification / tools) 을 String → Object 자동 변환.
- 모범 패턴 (`AuditLogService.processChanges`, `BusinessRuleService.parseJsonField`,
`DataflowDiagramService.parseJsonbFields`) 동일하게 적용.
- model 의 String getter 는 그대로 유지 — `MultiAgentExecutionEngine` 등
내부 LLM 호출 chain 영향 없음 (`getEntityById` 분리).
- 컨트롤러 시그니처 generic 만 변경 (return type Map).
프론트엔드:
- `safeArray<T>` / `safeObject<T>` 헬퍼 (`lib/utils.ts`) — 백엔드가 미파싱 String 으로
올 때 graceful fallback. 빈 배열/객체 반환.
- `workspace/page.tsx` 멤버 카드:
- `safeArray(member.connectors)` 적용 → `.map()` 폭발 차단.
- 좁은 viewport 에서 한글 텍스트 한 글자씩 세로로 깨지던 문제 해결
(`flex-wrap` + `truncate` + `whitespace-nowrap` + `max-w` + `title`).
그렘린 1000마리 폭격 + architect 자문으로 발견. workspace `Application error`,
`memberConnectors.map is not a function` 모두 해결.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package com.erp.ai.controller;
|
||||
|
||||
import com.erp.ai.dto.ApiKeyCreateRequest;
|
||||
import com.erp.ai.model.AiAgentApiKey;
|
||||
import com.erp.ai.service.AiAgentApiKeyService;
|
||||
import com.erp.dto.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -22,7 +21,7 @@ public class AiAgentApiKeyController {
|
||||
private final AiAgentApiKeyService apiKeyService;
|
||||
|
||||
@GetMapping(value = {"", "/list"})
|
||||
public ResponseEntity<ApiResponse<List<AiAgentApiKey>>> list(
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> list(
|
||||
@RequestAttribute(value = "user_id", required = false) String userId,
|
||||
@RequestAttribute(value = "role", required = false) String role) {
|
||||
boolean isAdmin = "SUPER_ADMIN".equals(role) || "COMPANY_ADMIN".equals(role);
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/ai-agents")
|
||||
@@ -20,7 +21,7 @@ public class AiAgentController {
|
||||
private final AiAgentService aiAgentService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<ApiResponse<List<AiAgent>>> list(
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> list(
|
||||
@RequestAttribute(value = "company_code", required = false) String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestParam(required = false) String status,
|
||||
@@ -34,8 +35,8 @@ public class AiAgentController {
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<ApiResponse<AiAgent>> getById(@PathVariable long id) {
|
||||
AiAgent agent = aiAgentService.getById(id);
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getById(@PathVariable long id) {
|
||||
Map<String, Object> agent = aiAgentService.getById(id);
|
||||
if (agent == null) {
|
||||
return ResponseEntity.status(404).body(ApiResponse.error("에이전트를 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* AI API 키 관리 서비스.
|
||||
@@ -37,8 +39,41 @@ public class AiAgentApiKeyService {
|
||||
@Qualifier("aiObjectMapper")
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public List<AiAgentApiKey> list(String userId, boolean isAdmin) {
|
||||
return isAdmin ? apiKeyMapper.listAll() : apiKeyMapper.listByUser(userId);
|
||||
public List<Map<String, Object>> list(String userId, boolean isAdmin) {
|
||||
List<AiAgentApiKey> keys = isAdmin ? apiKeyMapper.listAll() : apiKeyMapper.listByUser(userId);
|
||||
return keys.stream().map(this::toResponseMap).toList();
|
||||
}
|
||||
|
||||
private Map<String, Object> toResponseMap(AiAgentApiKey k) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("id", k.getId());
|
||||
row.put("name", k.getName());
|
||||
row.put("key_prefix", k.getKey_prefix());
|
||||
row.put("user_id", k.getUser_id());
|
||||
row.put("company_code", k.getCompany_code());
|
||||
row.put("agent_id", k.getAgent_id());
|
||||
row.put("permissions", k.getPermissions());
|
||||
row.put("rate_limit", k.getRate_limit());
|
||||
row.put("monthly_token_limit", k.getMonthly_token_limit());
|
||||
row.put("status", k.getStatus());
|
||||
row.put("last_used_at", k.getLast_used_at());
|
||||
row.put("usage_count", k.getUsage_count());
|
||||
row.put("total_tokens", k.getTotal_tokens());
|
||||
row.put("expires_at", k.getExpires_at());
|
||||
row.put("created_at", k.getCreated_at());
|
||||
parseJsonField(row, "permissions");
|
||||
return row;
|
||||
}
|
||||
|
||||
private void parseJsonField(Map<String, Object> row, String key) {
|
||||
Object val = row.get(key);
|
||||
if (val instanceof String s && !s.isBlank()) {
|
||||
try {
|
||||
row.put(key, objectMapper.readValue(s, Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AiAgentApiKey getById(long id) {
|
||||
|
||||
@@ -4,8 +4,10 @@ import com.erp.ai.mapper.AiAgentConversationMapper;
|
||||
import com.erp.ai.mapper.AiAgentMessageMapper;
|
||||
import com.erp.ai.model.AiAgentConversation;
|
||||
import com.erp.ai.model.AiAgentMessage;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -26,12 +28,15 @@ public class AiAgentConversationService {
|
||||
private final AiAgentConversationMapper conversationMapper;
|
||||
private final AiAgentMessageMapper messageMapper;
|
||||
|
||||
@Qualifier("aiObjectMapper")
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public Map<String, Object> list(int page, int limit, Long agentId) {
|
||||
int offset = (page - 1) * limit;
|
||||
List<AiAgentConversation> conversations = conversationMapper.list(agentId, limit, offset);
|
||||
int total = conversationMapper.count(agentId);
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("conversations", conversations);
|
||||
result.put("conversations", conversations.stream().map(this::convToResponseMap).toList());
|
||||
result.put("total", total);
|
||||
return result;
|
||||
}
|
||||
@@ -42,11 +47,57 @@ public class AiAgentConversationService {
|
||||
? messageMapper.listByConversationId(id)
|
||||
: List.of();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("conversation", conv);
|
||||
result.put("messages", messages);
|
||||
result.put("conversation", conv != null ? convToResponseMap(conv) : null);
|
||||
result.put("messages", messages.stream().map(this::messageToResponseMap).toList());
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<String, Object> convToResponseMap(AiAgentConversation c) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("id", c.getId());
|
||||
row.put("conversation_id", c.getConversation_id());
|
||||
row.put("agent_id", c.getAgent_id());
|
||||
row.put("user_id", c.getUser_id());
|
||||
row.put("api_key_id", c.getApi_key_id());
|
||||
row.put("title", c.getTitle());
|
||||
row.put("message_count", c.getMessage_count());
|
||||
row.put("total_tokens", c.getTotal_tokens());
|
||||
row.put("status", c.getStatus());
|
||||
row.put("metadata", c.getMetadata());
|
||||
row.put("created_at", c.getCreated_at());
|
||||
row.put("updated_at", c.getUpdated_at());
|
||||
row.put("agent_name", c.getAgent_name());
|
||||
row.put("display_name", c.getDisplay_name());
|
||||
parseJsonField(row, "metadata");
|
||||
return row;
|
||||
}
|
||||
|
||||
private Map<String, Object> messageToResponseMap(AiAgentMessage m) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("id", m.getId());
|
||||
row.put("conversation_id", m.getConversation_id());
|
||||
row.put("role", m.getRole());
|
||||
row.put("content", m.getContent());
|
||||
row.put("tool_calls", m.getTool_calls());
|
||||
row.put("token_count", m.getToken_count());
|
||||
row.put("metadata", m.getMetadata());
|
||||
row.put("created_at", m.getCreated_at());
|
||||
parseJsonField(row, "tool_calls");
|
||||
parseJsonField(row, "metadata");
|
||||
return row;
|
||||
}
|
||||
|
||||
private void parseJsonField(Map<String, Object> row, String key) {
|
||||
Object val = row.get(key);
|
||||
if (val instanceof String s && !s.isBlank()) {
|
||||
try {
|
||||
row.put(key, objectMapper.readValue(s, Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AiAgentConversation createConversation(Long agentId, String userId, Long apiKeyId) {
|
||||
AiAgentConversation conv = new AiAgentConversation();
|
||||
|
||||
@@ -53,14 +53,46 @@ public class AiAgentGroupService {
|
||||
result.put("created_by", group.getCreated_by());
|
||||
result.put("created_at", group.getCreated_at());
|
||||
result.put("updated_at", group.getUpdated_at());
|
||||
result.put("members", members);
|
||||
result.put("members", members.stream().map(this::memberToResponseMap).toList());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* AiAgentGroupMember → API 응답용 Map.
|
||||
* JSONB(::text) 컬럼 connectors / config 를 Object 로 파싱.
|
||||
* MultiAgentExecutionEngine 의 raw String 사용처는 model 을 직접 사용하므로 영향 없음.
|
||||
*/
|
||||
private Map<String, Object> memberToResponseMap(AiAgentGroupMember m) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("id", m.getId());
|
||||
row.put("group_id", m.getGroup_id());
|
||||
row.put("agent_id", m.getAgent_id());
|
||||
row.put("role_name", m.getRole_name());
|
||||
row.put("connectors", m.getConnectors());
|
||||
row.put("execution_order", m.getExecution_order());
|
||||
row.put("config", m.getConfig());
|
||||
row.put("created_at", m.getCreated_at());
|
||||
row.put("updated_at", m.getUpdated_at());
|
||||
row.put("agent_name", m.getAgent_name());
|
||||
row.put("agent_model", m.getAgent_model());
|
||||
parseJsonField(row, "connectors");
|
||||
parseJsonField(row, "config");
|
||||
return row;
|
||||
}
|
||||
|
||||
private void parseJsonField(Map<String, Object> row, String key) {
|
||||
Object val = row.get(key);
|
||||
if (val instanceof String s && !s.isBlank()) {
|
||||
try {
|
||||
row.put(key, objectMapper.readValue(s, Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AiAgentGroup getEntityById(long id) {
|
||||
AiAgentGroup g = groupMapper.getById(id);
|
||||
if (g == null) return null;
|
||||
return g;
|
||||
return groupMapper.getById(id);
|
||||
}
|
||||
|
||||
public AiAgentGroup getByGroupId(String groupId) {
|
||||
|
||||
@@ -5,8 +5,10 @@ import com.erp.ai.exception.AiAgentException;
|
||||
import com.erp.ai.mapper.AiLlmProviderMapper;
|
||||
import com.erp.ai.model.AiLlmProvider;
|
||||
import com.erp.ai.util.AesGcmCipher;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -28,6 +30,9 @@ public class AiAgentProviderService {
|
||||
private final AiLlmProviderMapper providerMapper;
|
||||
private final AesGcmCipher cipher;
|
||||
|
||||
@Qualifier("aiObjectMapper")
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** 키 마스킹된 목록 */
|
||||
public List<Map<String, Object>> list() {
|
||||
return providerMapper.listAll().stream()
|
||||
@@ -35,7 +40,14 @@ public class AiAgentProviderService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
public AiLlmProvider getById(long id) {
|
||||
public Map<String, Object> getById(long id) {
|
||||
AiLlmProvider p = providerMapper.getById(id);
|
||||
if (p == null) return null;
|
||||
return toMaskedMap(p);
|
||||
}
|
||||
|
||||
/** 내부용 — model 원본 반환 (getActiveProviders, LLM 호출 경로) */
|
||||
public AiLlmProvider getEntityById(long id) {
|
||||
return providerMapper.getById(id);
|
||||
}
|
||||
|
||||
@@ -121,6 +133,18 @@ public class AiAgentProviderService {
|
||||
// 마스킹: 마지막 4글자만 노출
|
||||
String enc = p.getApi_key_encrypted();
|
||||
m.put("api_key_masked", enc != null && enc.length() >= 4 ? "****" + enc.substring(enc.length() - 4) : "");
|
||||
parseJsonField(m, "config");
|
||||
return m;
|
||||
}
|
||||
|
||||
private void parseJsonField(Map<String, Object> row, String key) {
|
||||
Object val = row.get(key);
|
||||
if (val instanceof String s && !s.isBlank()) {
|
||||
try {
|
||||
row.put(key, objectMapper.readValue(s, Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,15 +30,22 @@ public class AiAgentService {
|
||||
@Qualifier("aiObjectMapper")
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public List<AiAgent> list(String status, String companyCode, String search) {
|
||||
public List<Map<String, Object>> list(String status, String companyCode, String search) {
|
||||
Map<String, Object> filters = new HashMap<>();
|
||||
if (status != null) filters.put("status", status);
|
||||
if (companyCode != null) filters.put("company_code", companyCode);
|
||||
if (search != null && !search.isBlank()) filters.put("search", "%" + search + "%");
|
||||
return agentMapper.list(filters);
|
||||
return agentMapper.list(filters).stream().map(this::toResponseMap).toList();
|
||||
}
|
||||
|
||||
public AiAgent getById(long id) {
|
||||
public Map<String, Object> getById(long id) {
|
||||
AiAgent agent = agentMapper.getById(id);
|
||||
if (agent == null) return null;
|
||||
return toResponseMap(agent);
|
||||
}
|
||||
|
||||
/** 내부용 — model 원본 반환 (MultiAgentExecutionEngine 등 LLM 호출 경로는 agentMapper 직접 사용) */
|
||||
public AiAgent getEntityById(long id) {
|
||||
return agentMapper.getById(id);
|
||||
}
|
||||
|
||||
@@ -46,6 +53,37 @@ public class AiAgentService {
|
||||
return agentMapper.getByAgentId(agentId);
|
||||
}
|
||||
|
||||
private Map<String, Object> toResponseMap(AiAgent a) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("id", a.getId());
|
||||
row.put("agent_id", a.getAgent_id());
|
||||
row.put("name", a.getName());
|
||||
row.put("description", a.getDescription());
|
||||
row.put("model", a.getModel());
|
||||
row.put("system_prompt", a.getSystem_prompt());
|
||||
row.put("tools", a.getTools());
|
||||
row.put("config", a.getConfig());
|
||||
row.put("status", a.getStatus());
|
||||
row.put("company_code", a.getCompany_code());
|
||||
row.put("created_by", a.getCreated_by());
|
||||
row.put("created_at", a.getCreated_at());
|
||||
row.put("updated_at", a.getUpdated_at());
|
||||
parseJsonField(row, "tools");
|
||||
parseJsonField(row, "config");
|
||||
return row;
|
||||
}
|
||||
|
||||
private void parseJsonField(Map<String, Object> row, String key) {
|
||||
Object val = row.get(key);
|
||||
if (val instanceof String s && !s.isBlank()) {
|
||||
try {
|
||||
row.put(key, objectMapper.readValue(s, Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AiAgent create(AgentRequest req, String userId) {
|
||||
if (req.getAgent_id() == null || req.getAgent_id().isBlank()) {
|
||||
|
||||
@@ -24,7 +24,9 @@ import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TimeZone;
|
||||
|
||||
/**
|
||||
@@ -66,14 +68,53 @@ public class AiSchedulerService {
|
||||
}
|
||||
}
|
||||
|
||||
public List<AiAgentSchedule> list() {
|
||||
return scheduleMapper.listAll();
|
||||
public List<Map<String, Object>> list() {
|
||||
return scheduleMapper.listAll().stream().map(this::toResponseMap).toList();
|
||||
}
|
||||
|
||||
public AiAgentSchedule getById(long id) {
|
||||
public Map<String, Object> getById(long id) {
|
||||
AiAgentSchedule s = scheduleMapper.getById(id);
|
||||
if (s == null) return null;
|
||||
return toResponseMap(s);
|
||||
}
|
||||
|
||||
/** 내부용 — model 원본 반환 (Quartz 등록 경로) */
|
||||
public AiAgentSchedule getEntityById(long id) {
|
||||
return scheduleMapper.getById(id);
|
||||
}
|
||||
|
||||
private Map<String, Object> toResponseMap(AiAgentSchedule s) {
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("id", s.getId());
|
||||
row.put("name", s.getName());
|
||||
row.put("group_id", s.getGroup_id());
|
||||
row.put("cron_expression", s.getCron_expression());
|
||||
row.put("timezone", s.getTimezone());
|
||||
row.put("input_message", s.getInput_message());
|
||||
row.put("notification", s.getNotification());
|
||||
row.put("is_active", s.getIs_active());
|
||||
row.put("last_run_at", s.getLast_run_at());
|
||||
row.put("run_count", s.getRun_count());
|
||||
row.put("company_code", s.getCompany_code());
|
||||
row.put("created_by", s.getCreated_by());
|
||||
row.put("created_at", s.getCreated_at());
|
||||
row.put("updated_at", s.getUpdated_at());
|
||||
row.put("group_name", s.getGroup_name());
|
||||
parseJsonField(row, "notification");
|
||||
return row;
|
||||
}
|
||||
|
||||
private void parseJsonField(Map<String, Object> row, String key) {
|
||||
Object val = row.get(key);
|
||||
if (val instanceof String s && !s.isBlank()) {
|
||||
try {
|
||||
row.put(key, objectMapper.readValue(s, Object.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse JSONB field '{}': {}", key, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AiAgentSchedule create(ScheduleRequest req, String userId) {
|
||||
if (!CronExpression.isValidExpression(req.getCron_expression())) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { toast } from "sonner";
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { safeArray } from "@/lib/utils";
|
||||
|
||||
const CONNECTOR_ICONS: Record<string, any> = { database: Database, rest_api: Globe, file: FileText, plc: Cpu, crawler: Bug };
|
||||
const CONNECTOR_COLORS: Record<string, string> = { database: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400", rest_api: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400", file: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400", plc: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", crawler: "bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400" };
|
||||
@@ -595,7 +596,7 @@ const CONNECTOR_TYPE_OPTIONS = [
|
||||
function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRemoveConnector }: MemberCardProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
const memberConnectors = member.connectors || [];
|
||||
const memberConnectors = safeArray<any>(member.connectors);
|
||||
|
||||
const filteredConnectors = selectedType
|
||||
? connectors.filter((c) => c.type === selectedType)
|
||||
@@ -603,14 +604,14 @@ function MemberCardContent({ member, connectors, onRemove, onAddConnector, onRem
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Bot className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="text-xs font-semibold">{member.role_name}</span>
|
||||
<Badge variant="outline" className="h-4 text-[9px]">{member.agent_name || "에이전트"}</Badge>
|
||||
<div className="flex flex-wrap items-center gap-1 min-w-0">
|
||||
<Bot className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
<span className="text-xs font-semibold truncate min-w-0 max-w-full" title={member.role_name}>{member.role_name}</span>
|
||||
<Badge variant="outline" className="h-4 text-[9px] shrink-0 max-w-[140px] truncate inline-block whitespace-nowrap" title={member.agent_name}>{member.agent_name || "에이전트"}</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 ml-5 text-[10px] text-muted-foreground font-mono">{member.agent_model}</p>
|
||||
<p className="mt-0.5 ml-5 text-[10px] text-muted-foreground font-mono truncate" title={member.agent_model}>{member.agent_model}</p>
|
||||
</div>
|
||||
<button className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive hover:bg-destructive/10" onClick={onRemove}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
|
||||
@@ -5,6 +5,39 @@ export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* 백엔드 JSONB(::text) 응답이 String 으로 올 때 안전하게 array 로 변환.
|
||||
* INVYONE 백엔드의 모든 ::text AS jsonb 컬럼이 가끔 미파싱 String 으로 옵니다.
|
||||
*/
|
||||
export const safeArray = <T = unknown>(v: unknown): T[] => {
|
||||
if (Array.isArray(v)) return v as T[];
|
||||
if (typeof v === "string") {
|
||||
try {
|
||||
const p = JSON.parse(v);
|
||||
return Array.isArray(p) ? (p as T[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* JSONB string → object 안전 변환. array/null/undefined → 빈 객체.
|
||||
*/
|
||||
export const safeObject = <T extends Record<string, unknown> = Record<string, unknown>>(v: unknown): T => {
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) return v as T;
|
||||
if (typeof v === "string") {
|
||||
try {
|
||||
const p = JSON.parse(v);
|
||||
return p && typeof p === "object" && !Array.isArray(p) ? (p as T) : ({} as T);
|
||||
} catch {
|
||||
return {} as T;
|
||||
}
|
||||
}
|
||||
return {} as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* 파일 크기를 사람이 읽기 쉬운 형태로 포맷팅
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user