- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함 - FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함 - AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함 - AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함 - DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함 - README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
This commit is contained in:
@@ -1541,3 +1541,10 @@ MIT License
|
||||
- [AgentLoopTransitions.Verification.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Verification.cs), [AgentLoopTransitions.Documents.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopTransitions.Documents.cs), [AgentLoopCompactionPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopCompactionPolicy.cs)로 검증/fallback/compact 정책 메서드를 분리해 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)의 책임을 더 줄였습니다.
|
||||
- [ChatWindow.TranscriptVirtualization.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptVirtualization.cs)에서 off-screen 버블 캐시를 pruning하도록 바꿨고, [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml)의 transcript `ListBox`에는 deferred scrolling과 작은 cache length를 적용해 더 강한 가상화 리스트 방향으로 정리했습니다.
|
||||
- [ChatWindow.TranscriptRendering.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.TranscriptRendering.cs)는 렌더 시간, visible message/event 수, hidden count, lightweight mode 여부를 함께 기록해 실사용 세션에서 버벅임을 실제 수치로 판단할 수 있게 됐습니다.
|
||||
- 업데이트: 2026-04-09 10:36 (KST)
|
||||
- `claude-code`의 선택적 탐색 흐름을 다시 대조해, Cowork/Code가 질문 범위와 무관한 워크스페이스 전체를 훑는 경향을 줄이기 시작했습니다.
|
||||
- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)의 Cowork/Code 시스템 프롬프트를 조정해 `folder_map`을 항상 첫 단계로 요구하지 않고, 좁은 질문에서는 `glob/grep + targeted read`를 우선하도록 바꿨습니다.
|
||||
- [FolderMapTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/FolderMapTool.cs)는 기본 depth를 2로, `include_files` 기본값을 `false`로 조정해 첫 탐색 폭을 더 보수적으로 만들었습니다.
|
||||
- [MultiReadTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MultiReadTool.cs)는 한 번에 읽을 수 있는 최대 파일 수를 20개에서 8개로 낮춰 초기 과탐색 토큰 낭비를 줄이도록 했습니다.
|
||||
- [AgentLoopExplorationPolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs)와 [AgentLoopService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/AgentLoopService.cs)에 탐색 범위 분류기와 broad-scan corrective hint를 추가해, 좁은 질문에서 반복적인 `folder_map`/대량 `multi_read`가 나오면 관련 파일만 다시 고르도록 교정합니다.
|
||||
- [AgentPerformanceLogService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentPerformanceLogService.cs)는 `%APPDATA%\\AxCopilot\\perf`에 `exploration_breadth` 로그를 남겨 `folder_map` 호출 수, 총 읽은 파일 수, broad scan 여부를 실사용 기준으로 확인할 수 있게 했습니다.
|
||||
|
||||
5935
docs/DEVELOPMENT.md
5935
docs/DEVELOPMENT.md
File diff suppressed because it is too large
Load Diff
296
docs/STRUCTURAL_REFACTORING_PLAN.md
Normal file
296
docs/STRUCTURAL_REFACTORING_PLAN.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# 구조적 리팩토링 개발 계획
|
||||
|
||||
> 작성: 2026-04-09
|
||||
|
||||
---
|
||||
|
||||
## 배경
|
||||
|
||||
ChatWindow의 두 가지 구조적 결함이 장시간 사용 시 메모리 누수와 UI 프레임 드롭을 유발합니다.
|
||||
|
||||
1. **람다 이벤트 핸들러 누적** — 동적 UI 요소에 붙인 람다 핸들러가 `Children.Clear()` 후에도 GC되지 않음
|
||||
2. **스트리밍 중 인크리멘탈 렌더 실패** — prefix 키 비교가 스트리밍 상황에서 항상 실패하여 전체 재빌드로 폴백
|
||||
|
||||
---
|
||||
|
||||
## 과제 A: 람다 이벤트 핸들러 누적 해소
|
||||
|
||||
### 현황
|
||||
|
||||
26개소 이상에서 동적 UI 요소(Border, TextBlock, Button)에 람다 `+=` 핸들러를 붙이고, 컨테이너 `.Clear()` 시 핸들러를 해제하지 않음. 클로저가 캡처한 변수(UI 요소, 문자열)가 GC 대상에서 제외됨.
|
||||
|
||||
### 영향도별 분류
|
||||
|
||||
| 빈도 | 파일 | Clear 대상 | 핸들러 수/회 | 추정 누적 |
|
||||
|------|------|-----------|-------------|----------|
|
||||
| **높음** | `ConversationListPresentation.cs` | `ConversationPanel.Children` | 대화당 5개 × 50항목 = 250 | 탭 전환/필터마다 250개 |
|
||||
| **높음** | `TopicPresetPresentation.cs` | `TopicButtonPanel.Children` | 프리셋당 3개 × 15항목 = 45 | 탭 전환마다 45개 |
|
||||
| **중간** | `PermissionPresentation.cs` | `PermissionItems.Children` | 항목당 4개 × 20항목 = 80 | 권한 팝업 열 때마다 80개 |
|
||||
| **중간** | `PreviewPresentation.cs` | `PreviewTabPanel.Children` | 탭당 5개 × 10탭 = 50 | 미리보기 탭 변경마다 50개 |
|
||||
| **중간** | `ComposerQueuePresentation.cs` | `DraftQueuePanel.Children` | 항목당 2개 × 5항목 = 10 | 큐 갱신마다 10개 |
|
||||
| **중간** | `GitBranchPresentation.cs` | `GitBranchItems.Children` | 항목당 3개 × 20항목 = 60 | 브랜치 팝업 열 때마다 60개 |
|
||||
| **낮음** | `FileBrowserPresentation.cs` | `FileTreeView.Items` | 항목당 3개 × 100항목 = 300 | 트리 재구축마다 300개 |
|
||||
|
||||
### 설계 방침
|
||||
|
||||
**이벤트 위임(Event Delegation) 패턴** 적용 — 개별 자식 요소에 핸들러를 붙이지 않고, 부모 컨테이너에 단일 핸들러를 두고 `e.Source`/`e.OriginalSource`로 분기.
|
||||
|
||||
### 단계별 구현 계획
|
||||
|
||||
#### Phase 1: 고빈도 대상 (추정 공수: 2시간)
|
||||
|
||||
**1-1. ConversationListPresentation.cs**
|
||||
|
||||
현재:
|
||||
```csharp
|
||||
// 대화 항목마다 5개 람다 핸들러
|
||||
border.MouseEnter += (s, _) => { ... };
|
||||
border.MouseLeave += (s, _) => { ... };
|
||||
border.MouseLeftButtonDown += (s, _) => { ... };
|
||||
border.MouseRightButtonUp += (s, _) => { ... };
|
||||
border.MouseLeftButtonUp += (s, _) => { ... };
|
||||
ConversationPanel.Children.Add(border);
|
||||
```
|
||||
|
||||
변경 전략:
|
||||
```csharp
|
||||
// ConversationPanel에 단일 핸들러 (생성자에서 1회 등록)
|
||||
ConversationPanel.MouseEnter += ConversationPanel_MouseEnter;
|
||||
ConversationPanel.MouseLeave += ConversationPanel_MouseLeave;
|
||||
ConversationPanel.MouseLeftButtonDown += ConversationPanel_MouseLeftButtonDown;
|
||||
// ...
|
||||
|
||||
private void ConversationPanel_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// e.OriginalSource에서 가장 가까운 Border를 찾아 DataContext의 ConversationMeta로 분기
|
||||
if (FindAncestor<Border>(e.OriginalSource as DependencyObject) is { Tag: string conversationId })
|
||||
HandleConversationClick(conversationId);
|
||||
}
|
||||
```
|
||||
|
||||
핵심 변경:
|
||||
- 각 Border의 `Tag` 속성에 `conversationId` 저장
|
||||
- `ConversationPanel`에 이벤트 5개를 생성자에서 1회만 등록
|
||||
- `AddConversationItem()`에서 람다 핸들러 제거
|
||||
- hover 효과는 `ConversationPanel_MouseMove`에서 `VisualTreeHelper.HitTest`로 처리
|
||||
|
||||
**1-2. TopicPresetPresentation.cs**
|
||||
|
||||
동일 패턴 적용:
|
||||
- `TopicButtonPanel`에 `PreviewMouseLeftButtonDown`, `MouseEnter`, `MouseLeave` 3개만 등록
|
||||
- 각 프리셋 카드의 `Tag`에 프리셋 ID 저장
|
||||
- `AttachTopicCardHover` 메서드를 제거하고 부모 위임으로 대체
|
||||
|
||||
#### Phase 2: 중빈도 대상 (추정 공수: 2시간)
|
||||
|
||||
**2-1. PermissionPresentation.cs** — 동일 패턴
|
||||
**2-2. PreviewPresentation.cs** — 탭 닫기 버튼만 주의 (부모 위임 + `Tag` 분기)
|
||||
**2-3. ComposerQueuePresentation.cs** — 큐 항목 적음, 간단
|
||||
**2-4. GitBranchPresentation.cs** — `CreateFlatPopupRow()` 내부 핸들러를 부모 위임으로 전환
|
||||
|
||||
#### Phase 3: 저빈도 + 공통 유틸 (추정 공수: 1시간)
|
||||
|
||||
**3-1. FileBrowserPresentation.cs** — TreeViewItem은 WPF 내부 이벤트 라우팅이 복잡하므로, Clear 전에 명시적 해제 방식 적용:
|
||||
|
||||
```csharp
|
||||
private void DetachFileTreeHandlers(ItemCollection items)
|
||||
{
|
||||
foreach (var item in items.OfType<TreeViewItem>())
|
||||
{
|
||||
item.Expanded -= FileTreeItem_Expanded;
|
||||
item.MouseDoubleClick -= FileTreeItem_DoubleClick;
|
||||
item.MouseRightButtonUp -= FileTreeItem_RightClick;
|
||||
DetachFileTreeHandlers(item.Items); // 재귀
|
||||
}
|
||||
}
|
||||
// BuildFileTree() 시작 시 DetachFileTreeHandlers(FileTreeView.Items) 호출
|
||||
```
|
||||
|
||||
**3-2. 공통 헬퍼 추가**
|
||||
|
||||
```csharp
|
||||
// ChatWindow.VisualInteractionHelpers.cs에 추가
|
||||
private static T? FindAncestorWithTag<T>(DependencyObject? source) where T : FrameworkElement
|
||||
{
|
||||
while (source != null)
|
||||
{
|
||||
if (source is T fe && fe.Tag != null)
|
||||
return fe;
|
||||
source = VisualTreeHelper.GetParent(source);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### 검증 방법
|
||||
|
||||
1. 수정 전/후 Visual Studio Memory Profiler로 GC Gen2 객체 수 비교
|
||||
2. 대화 탭 100회 전환 후 핸들러 참조 수 변화 측정
|
||||
3. UI 기능 회귀 테스트 (hover 효과, 클릭, 우클릭 메뉴)
|
||||
|
||||
---
|
||||
|
||||
## 과제 B: 스트리밍 인크리멘탈 렌더 실패 해소
|
||||
|
||||
### 현황
|
||||
|
||||
`TryApplyIncrementalTranscriptRender`는 이론적으로 새 항목만 추가/교체하지만, 스트리밍 중 3가지 이유로 항상 실패하여 `ApplyFullTranscriptRender`로 폴백합니다:
|
||||
|
||||
| 실패 원인 | 위치 | 설명 |
|
||||
|----------|------|------|
|
||||
| `hiddenCount` 불일치 | `BuildTranscriptRenderPlan` L39 | 스트리밍 시 `GetActiveTimelineRenderLimit()`가 다른 값 반환 → `_lastRenderedHiddenCount != hiddenCount` |
|
||||
| prefix 키 불일치 | `TryApplyIncrementalTranscriptRender` L13 | hiddenCount 변화로 visible 범위가 밀려서 첫 번째 키가 달라짐 |
|
||||
| `_agentLiveContainer` 존재 | `BuildTranscriptRenderPlan` L36 | 라이브 카드가 transcript에 삽입되면 `hasExternalChildren=true` → `canIncremental=false` |
|
||||
|
||||
### 현재 렌더 흐름
|
||||
|
||||
```
|
||||
RenderMessages(preserveViewport: true) — 350ms/2200ms 타이머
|
||||
→ BuildTranscriptRenderPlan()
|
||||
→ GetActiveTimelineRenderLimit() → 스트리밍이면 더 작은 값
|
||||
→ hiddenCount 계산 → 매번 다름 → canIncremental=false
|
||||
→ TryApplyIncrementalTranscriptRender() → 실패
|
||||
→ ApplyFullTranscriptRender() → ClearTranscriptElements() + 전체 재구축
|
||||
```
|
||||
|
||||
### 설계 방침
|
||||
|
||||
**스트리밍 전용 경량 업데이트 경로** 도입 — 스트리밍 중에는 전체 타임라인을 재구축하지 않고, 마지막 메시지 영역만 갱신.
|
||||
|
||||
### 단계별 구현 계획
|
||||
|
||||
#### Phase 1: hiddenCount 안정화 (추정 공수: 30분)
|
||||
|
||||
**문제**: 스트리밍 중 `GetActiveTimelineRenderLimit()`가 더 작은 값을 반환하여 hiddenCount가 변동.
|
||||
|
||||
**수정**: 스트리밍 중에는 `_lastRenderedHiddenCount`를 고정하여 비교 안정화.
|
||||
|
||||
```csharp
|
||||
// BuildTranscriptRenderPlan 수정
|
||||
var effectiveRenderLimit = GetActiveTimelineRenderLimit();
|
||||
var hiddenCount = Math.Max(0, orderedTimeline.Count - effectiveRenderLimit);
|
||||
|
||||
// 스트리밍 중에는 hiddenCount가 증가만 허용 (줄어들지 않음)
|
||||
// → 이미 렌더링된 stable 항목을 숨기지 않아 prefix 불일치 방지
|
||||
if (_isStreaming && hiddenCount < _lastRenderedHiddenCount)
|
||||
hiddenCount = _lastRenderedHiddenCount;
|
||||
```
|
||||
|
||||
#### Phase 2: _agentLiveContainer 분리 (추정 공수: 1시간)
|
||||
|
||||
**문제**: 라이브 진행 카드(`_agentLiveContainer`)가 transcript 패널에 삽입되면 `hasExternalChildren=true` → 인크리멘탈 불가.
|
||||
|
||||
**수정**: 라이브 카드를 transcript 패널 밖의 별도 오버레이로 분리.
|
||||
|
||||
```xml
|
||||
<!-- ChatWindow.xaml 수정 -->
|
||||
<Grid>
|
||||
<!-- 기존 transcript -->
|
||||
<ScrollViewer x:Name="TranscriptScroll">
|
||||
<StackPanel x:Name="TranscriptPanel" />
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- 라이브 진행 카드 — transcript 밖 하단 오버레이 -->
|
||||
<Border x:Name="AgentLiveOverlay"
|
||||
VerticalAlignment="Bottom"
|
||||
Margin="0,0,0,8" />
|
||||
</Grid>
|
||||
```
|
||||
|
||||
변경 사항:
|
||||
- `_agentLiveContainer`를 `AgentLiveOverlay.Child`로 설정 (transcript 패널에서 분리)
|
||||
- `ApplyFullTranscriptRender`에서 `_agentLiveContainer` 삽입 코드 제거
|
||||
- `hasExternalChildren` 체크 불필요 → `canIncremental` 조건 단순화
|
||||
- 스크롤 위치 조정 로직은 오버레이 높이를 반영
|
||||
|
||||
#### Phase 3: 스트리밍 전용 append-only 경로 (추정 공수: 1.5시간)
|
||||
|
||||
**목표**: 스트리밍 중에는 `TryApplyIncrementalTranscriptRender`의 prefix 비교를 우회하고, 새 항목만 추가하는 빠른 경로 사용.
|
||||
|
||||
```csharp
|
||||
// TranscriptRenderExecution.cs에 추가
|
||||
private bool TryApplyStreamingAppendRender(TranscriptRenderPlan renderPlan)
|
||||
{
|
||||
// 스트리밍 전용: prefix 비교 대신 stable 키 집합의 부분집합 관계만 확인
|
||||
if (!_isStreaming || _lastRenderedTimelineKeys.Count == 0)
|
||||
return false;
|
||||
|
||||
// stable 키가 변하지 않았는지 확인 (순서 무관, 존재 여부만)
|
||||
var lastStableKeys = new HashSet<string>(
|
||||
_lastRenderedTimelineKeys.Where(k => !k.StartsWith("_live_")),
|
||||
StringComparer.Ordinal);
|
||||
var newStableKeys = renderPlan.NewKeys
|
||||
.Where(k => !k.StartsWith("_live_"))
|
||||
.ToList();
|
||||
|
||||
// 새 stable 키가 기존의 superset이면 append 가능
|
||||
if (!lastStableKeys.IsSubsetOf(newStableKeys))
|
||||
return false;
|
||||
|
||||
// 1. 기존 live 항목 제거
|
||||
for (var i = 0; i < renderPlan.PreviousLiveCount && GetTranscriptElementCount() > 0; i++)
|
||||
RemoveTranscriptElementAt(GetTranscriptElementCount() - 1);
|
||||
|
||||
// 2. 새로 추가된 stable 항목 렌더
|
||||
foreach (var item in renderPlan.VisibleTimeline)
|
||||
{
|
||||
if (!lastStableKeys.Contains(item.Key))
|
||||
item.Render();
|
||||
}
|
||||
|
||||
// 3. 새 live 항목 렌더
|
||||
foreach (var item in renderPlan.VisibleTimeline.Where(
|
||||
t => t.Key.StartsWith("_live_")))
|
||||
item.Render();
|
||||
|
||||
_lastRenderedTimelineKeys = renderPlan.NewKeys;
|
||||
_lastRenderedHiddenCount = renderPlan.HiddenCount;
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
호출 순서 수정 (TranscriptRendering.cs):
|
||||
```csharp
|
||||
if (!TryApplyStreamingAppendRender(renderPlan) // 스트리밍 전용 빠른 경로
|
||||
&& !TryApplyIncrementalTranscriptRender(renderPlan) // 일반 인크리멘탈
|
||||
)
|
||||
ApplyFullTranscriptRender(renderPlan); // 최후 수단
|
||||
```
|
||||
|
||||
#### Phase 4: 비가시 상태 렌더 차단 (추정 공수: 15분)
|
||||
|
||||
```csharp
|
||||
// RenderMessages 최상단에 추가
|
||||
if (WindowState == WindowState.Minimized || !IsVisible)
|
||||
return;
|
||||
```
|
||||
|
||||
### 검증 방법
|
||||
|
||||
1. 스트리밍 중 `ApplyFullTranscriptRender` 호출 횟수를 성능 로그로 측정 (수정 전 vs 후)
|
||||
2. 스트리밍 중 프레임 드롭 수 비교 (WPF Performance Toolkit)
|
||||
3. 50개 이상 메시지가 있는 대화에서 스트리밍 응답 시 스크롤 버벅임 확인
|
||||
|
||||
---
|
||||
|
||||
## 실행 계획 요약
|
||||
|
||||
| 단계 | 과제 | 작업 | 공수 | 우선순위 |
|
||||
|------|------|------|------|---------|
|
||||
| A-1 | 핸들러 | ConversationList + TopicPreset 이벤트 위임 | 2h | P1 | ✅ 완료 (2026-04-09) |
|
||||
| B-1 | 렌더 | hiddenCount 안정화 | 30m | P1 | ✅ 완료 (2026-04-09) |
|
||||
| B-4 | 렌더 | 비가시 상태 렌더 차단 | 15m | P1 | ✅ 완료 (2026-04-09) |
|
||||
| B-2 | 렌더 | _agentLiveContainer 인크리멘탈 허용 | 1h | P2 | ✅ 완료 (2026-04-09) |
|
||||
| B-3 | 렌더 | 스트리밍 append-only 경로 | 1.5h | P2 | ✅ 완료 (2026-04-09) |
|
||||
| A-2 | 핸들러 | Permission + Preview + GitBranch 위임 | 2h | P2 | ✅ 완료 (2026-04-09) |
|
||||
| A-3 | 핸들러 | FileBrowser 명시적 해제 + 공통 헬퍼 | 1h | P3 | ✅ 완료 (2026-04-09) |
|
||||
|
||||
**총 예상 공수**: ~8시간
|
||||
|
||||
### 위험 요소
|
||||
|
||||
| 위험 | 영향 | 대응 |
|
||||
|------|------|------|
|
||||
| 이벤트 위임 시 hover 효과 깨짐 | UI 품질 저하 | 기존 CSS-style 직접 설정 대신 VisualStateManager 활용 |
|
||||
| 인크리멘탈 렌더 중 DOM 불일치 | 렌더링 오류 | try/catch + 전체 재빌드 폴백 유지 |
|
||||
| 라이브 카드 오버레이 분리 시 스크롤 동기화 | 라이브 카드가 transcript와 분리되어 보임 | 스크롤 이벤트에서 오버레이 위치 동기화 |
|
||||
@@ -226,7 +226,7 @@ public static class AgentHookRunner
|
||||
|
||||
var structured = false;
|
||||
|
||||
if (root.TryGetProperty("updatedInput", out var inputProp) &&
|
||||
if (root.SafeTryGetProperty("updatedInput", out var inputProp) &&
|
||||
inputProp.ValueKind is not JsonValueKind.Null and not JsonValueKind.Undefined)
|
||||
{
|
||||
updatedInput = inputProp.Clone();
|
||||
@@ -245,10 +245,10 @@ public static class AgentHookRunner
|
||||
structured = true;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("message", out var msgProp) &&
|
||||
if (root.SafeTryGetProperty("message", out var msgProp) &&
|
||||
msgProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var msg = msgProp.GetString();
|
||||
var msg = msgProp.SafeGetString();
|
||||
if (!string.IsNullOrWhiteSpace(msg))
|
||||
message = msg.Trim();
|
||||
}
|
||||
@@ -281,8 +281,8 @@ public static class AgentHookRunner
|
||||
updatedPermissions = null;
|
||||
|
||||
JsonElement permProp;
|
||||
if (!(root.TryGetProperty("updatedPermissions", out permProp)
|
||||
|| root.TryGetProperty("permissionUpdates", out permProp)))
|
||||
if (!(root.SafeTryGetProperty("updatedPermissions", out permProp)
|
||||
|| root.SafeTryGetProperty("permissionUpdates", out permProp)))
|
||||
return false;
|
||||
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -301,14 +301,14 @@ public static class AgentHookRunner
|
||||
if (entry.ValueKind != JsonValueKind.Object)
|
||||
continue;
|
||||
|
||||
if (!entry.TryGetProperty("tool", out var toolProp) || toolProp.ValueKind != JsonValueKind.String)
|
||||
if (!entry.SafeTryGetProperty("tool", out var toolProp) || toolProp.ValueKind != JsonValueKind.String)
|
||||
continue;
|
||||
|
||||
var tool = toolProp.GetString()?.Trim();
|
||||
var tool = toolProp.SafeGetString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(tool))
|
||||
continue;
|
||||
|
||||
if (entry.TryGetProperty("permission", out var permValue) &&
|
||||
if (entry.SafeTryGetProperty("permission", out var permValue) &&
|
||||
TryExtractPermissionValue(permValue, out var normalized))
|
||||
{
|
||||
map[tool] = normalized;
|
||||
@@ -329,7 +329,7 @@ public static class AgentHookRunner
|
||||
|
||||
if (permissionElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = permissionElement.GetString();
|
||||
var text = permissionElement.SafeGetString();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
normalized = text.Trim();
|
||||
@@ -337,10 +337,10 @@ public static class AgentHookRunner
|
||||
}
|
||||
}
|
||||
else if (permissionElement.ValueKind == JsonValueKind.Object &&
|
||||
permissionElement.TryGetProperty("permission", out var nestedPermission) &&
|
||||
permissionElement.SafeTryGetProperty("permission", out var nestedPermission) &&
|
||||
nestedPermission.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = nestedPermission.GetString();
|
||||
var text = nestedPermission.SafeGetString();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
normalized = text.Trim();
|
||||
@@ -354,12 +354,12 @@ public static class AgentHookRunner
|
||||
private static bool TryExtractAdditionalContext(JsonElement root, out string? additionalContext)
|
||||
{
|
||||
additionalContext = null;
|
||||
if (!root.TryGetProperty("additionalContext", out var ctxProp))
|
||||
if (!root.SafeTryGetProperty("additionalContext", out var ctxProp))
|
||||
return false;
|
||||
|
||||
if (ctxProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = ctxProp.GetString()?.Trim();
|
||||
var text = ctxProp.SafeGetString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
additionalContext = text;
|
||||
@@ -376,7 +376,7 @@ public static class AgentHookRunner
|
||||
if (part.ValueKind != JsonValueKind.String)
|
||||
continue;
|
||||
|
||||
var text = part.GetString()?.Trim();
|
||||
var text = part.SafeGetString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
chunks.Add(text);
|
||||
}
|
||||
@@ -394,10 +394,10 @@ public static class AgentHookRunner
|
||||
// 우선순위: message > content > text
|
||||
foreach (var key in new[] { "message", "content", "text" })
|
||||
{
|
||||
if (!ctxProp.TryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.String)
|
||||
if (!ctxProp.SafeTryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.String)
|
||||
continue;
|
||||
|
||||
var text = value.GetString()?.Trim();
|
||||
var text = value.SafeGetString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
additionalContext = text;
|
||||
|
||||
154
src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs
Normal file
154
src/AxCopilot/Services/Agent/AgentLoopExplorationPolicy.cs
Normal file
@@ -0,0 +1,154 @@
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
public partial class AgentLoopService
|
||||
{
|
||||
private enum ExplorationScope
|
||||
{
|
||||
Localized,
|
||||
TopicBased,
|
||||
RepoWide,
|
||||
OpenEnded,
|
||||
}
|
||||
|
||||
private sealed class ExplorationTrackingState
|
||||
{
|
||||
public ExplorationScope Scope { get; init; }
|
||||
public int FolderMapCalls { get; set; }
|
||||
public int TotalFilesRead { get; set; }
|
||||
public int MultiReadFilesRead { get; set; }
|
||||
public bool BroadScanDetected { get; set; }
|
||||
public bool SelectiveHit { get; set; }
|
||||
public bool CorrectiveHintInjected { get; set; }
|
||||
}
|
||||
|
||||
private static ExplorationScope ClassifyExplorationScope(string userQuery, string? activeTab)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userQuery))
|
||||
return ExplorationScope.OpenEnded;
|
||||
|
||||
var q = userQuery.Trim();
|
||||
var lower = q.ToLowerInvariant();
|
||||
|
||||
if (lower.Contains("전체") || lower.Contains("전반") || lower.Contains("코드베이스 전체") ||
|
||||
lower.Contains("repo-wide") || lower.Contains("repository-wide") || lower.Contains("전체 구조") ||
|
||||
lower.Contains("아키텍처") || lower.Contains("전체 점검"))
|
||||
return ExplorationScope.RepoWide;
|
||||
|
||||
if (q.Contains('.') || q.Contains('/') || q.Contains('\\') ||
|
||||
lower.Contains("file ") || lower.Contains("class ") || lower.Contains("method ") ||
|
||||
lower.Contains("function ") || lower.Contains("line ") || lower.Contains("bug") ||
|
||||
lower.Contains("오류") || lower.Contains("버그") || lower.Contains("예외"))
|
||||
return ExplorationScope.Localized;
|
||||
|
||||
if (lower.Contains("정리") || lower.Contains("요약") || lower.Contains("보고서") ||
|
||||
lower.Contains("주제") || lower.Contains("관련") || lower.Contains("분석"))
|
||||
return ExplorationScope.TopicBased;
|
||||
|
||||
return string.Equals(activeTab, "Code", StringComparison.OrdinalIgnoreCase)
|
||||
? ExplorationScope.Localized
|
||||
: ExplorationScope.OpenEnded;
|
||||
}
|
||||
|
||||
private static void InjectExplorationScopeGuidance(List<ChatMessage> messages, ExplorationScope scope)
|
||||
{
|
||||
var guidance = scope switch
|
||||
{
|
||||
ExplorationScope.Localized =>
|
||||
"Exploration scope = localized. Start with grep/glob and targeted file reads. Avoid folder_map unless structure is unclear.",
|
||||
ExplorationScope.TopicBased =>
|
||||
"Exploration scope = topic-based. Identify candidate files by topic keywords first, then read only a small targeted set.",
|
||||
ExplorationScope.RepoWide =>
|
||||
"Exploration scope = repo-wide. Broad structure inspection is allowed when needed.",
|
||||
_ =>
|
||||
"Exploration scope = open-ended. Expand gradually. Prefer selective discovery before broad scans."
|
||||
};
|
||||
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "system",
|
||||
Content = guidance
|
||||
});
|
||||
}
|
||||
|
||||
private static int CountMultiReadPaths(string argsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(argsJson);
|
||||
if (doc.RootElement.TryGetProperty("paths", out var pathsEl) && pathsEl.ValueKind == JsonValueKind.Array)
|
||||
return pathsEl.GetArrayLength();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static bool ShouldInjectExplorationCorrection(
|
||||
ExplorationTrackingState state,
|
||||
string toolName,
|
||||
string argsJson)
|
||||
{
|
||||
if (state.Scope is ExplorationScope.RepoWide or ExplorationScope.OpenEnded)
|
||||
return false;
|
||||
|
||||
if (state.FolderMapCalls >= 2)
|
||||
return true;
|
||||
|
||||
if (state.MultiReadFilesRead >= 6 || state.TotalFilesRead >= 8)
|
||||
return true;
|
||||
|
||||
if (string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(argsJson);
|
||||
var includeFiles = doc.RootElement.TryGetProperty("include_files", out var includeFilesEl) &&
|
||||
includeFilesEl.ValueKind is JsonValueKind.True or JsonValueKind.False &&
|
||||
includeFilesEl.GetBoolean();
|
||||
var depth = doc.RootElement.TryGetProperty("depth", out var depthEl) && depthEl.ValueKind == JsonValueKind.Number
|
||||
? depthEl.GetInt32()
|
||||
: 2;
|
||||
if (includeFiles || depth >= 3)
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void TrackExplorationToolUse(
|
||||
ExplorationTrackingState state,
|
||||
string toolName,
|
||||
string argsJson)
|
||||
{
|
||||
if (string.Equals(toolName, "folder_map", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
state.FolderMapCalls++;
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(toolName, "multi_read", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var count = CountMultiReadPaths(argsJson);
|
||||
state.MultiReadFilesRead += count;
|
||||
state.TotalFilesRead += count;
|
||||
if (count >= 6)
|
||||
state.BroadScanDetected = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(toolName, "file_read", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(toolName, "document_read", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
state.TotalFilesRead++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,13 @@ public partial class AgentLoopService
|
||||
private readonly ToolRegistry _tools;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly IToolExecutionCoordinator _toolExecutionCoordinator;
|
||||
|
||||
// P4: JsonSerializer 옵션 공유 — 익명 객체 직렬화 시 기본 옵션 재생성 방지
|
||||
private static readonly JsonSerializerOptions s_jsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
|
||||
@@ -168,6 +175,11 @@ public partial class AgentLoopService
|
||||
|
||||
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
|
||||
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
|
||||
var explorationState = new ExplorationTrackingState
|
||||
{
|
||||
Scope = ClassifyExplorationScope(userQuery, ActiveTab),
|
||||
SelectiveHit = true,
|
||||
};
|
||||
|
||||
// 워크플로우 상세 로그: 에이전트 루프 시작
|
||||
WorkflowLogService.LogAgentLifecycle(_conversationId, _currentRunId, "start",
|
||||
@@ -229,6 +241,17 @@ public partial class AgentLoopService
|
||||
|
||||
var context = BuildContext();
|
||||
InjectTaskTypeGuidance(messages, taskPolicy);
|
||||
InjectExplorationScopeGuidance(messages, explorationState.Scope);
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
explorationState.Scope switch
|
||||
{
|
||||
ExplorationScope.Localized => "국소 범위 탐색 · 관련 파일만 찾는 중",
|
||||
ExplorationScope.TopicBased => "주제 범위 탐색 · 관련 후보만 추리는 중",
|
||||
ExplorationScope.RepoWide => "저장소 범위 탐색 · 구조를 확인하는 중",
|
||||
_ => "점진 탐색 · 필요한 범위부터 확인하는 중",
|
||||
});
|
||||
if (!executionPolicy.ReduceEarlyMemoryPressure)
|
||||
InjectRecentFailureGuidance(messages, context, userQuery, taskPolicy);
|
||||
var runtimeOverrides = ResolveSkillRuntimeOverrides(messages);
|
||||
@@ -530,12 +553,15 @@ public partial class AgentLoopService
|
||||
}
|
||||
}
|
||||
|
||||
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
|
||||
var cachedActiveTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
|
||||
|
||||
// LLM에 도구 정의와 함께 요청
|
||||
List<LlmService.ContentBlock> blocks;
|
||||
var llmCallSw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var activeTools = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides);
|
||||
var activeTools = cachedActiveTools;
|
||||
if (activeTools.Count == 0)
|
||||
{
|
||||
EmitEvent(AgentEventType.Error, "", "현재 스킬 런타임 정책으로 사용 가능한 도구가 없습니다.");
|
||||
@@ -814,7 +840,7 @@ public partial class AgentLoopService
|
||||
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
|
||||
|
||||
var activeToolPreview = string.Join(", ",
|
||||
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
|
||||
cachedActiveTools
|
||||
.Select(t => t.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(10));
|
||||
@@ -857,7 +883,7 @@ public partial class AgentLoopService
|
||||
if (!string.IsNullOrEmpty(textResponse))
|
||||
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
|
||||
var planToolList = string.Join(", ",
|
||||
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
|
||||
cachedActiveTools
|
||||
.Select(t => t.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(10));
|
||||
@@ -1058,7 +1084,7 @@ public partial class AgentLoopService
|
||||
contentBlocks.Add(new { type = "text", text = textResponse });
|
||||
foreach (var tc in toolCalls)
|
||||
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
|
||||
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks });
|
||||
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }, s_jsonOpts);
|
||||
messages.Add(new ChatMessage { Role = "assistant", Content = assistantContent });
|
||||
|
||||
// 도구 실행 (병렬 모드: 읽기 전용 도구끼리 동시 실행)
|
||||
@@ -1136,15 +1162,16 @@ public partial class AgentLoopService
|
||||
if (toolCalls.Count == 0) continue;
|
||||
}
|
||||
|
||||
// P1: 도구 이름 목록을 foreach 밖에서 1회 계산 — 도구 배치 5개면 5회→1회
|
||||
var activeToolNames = cachedActiveTools
|
||||
.Select(t => t.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (var call in toolCalls)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
var activeToolNames = GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides)
|
||||
.Select(t => t.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var resolvedToolName = ResolveRequestedToolName(call.ToolName, activeToolNames);
|
||||
var globallyRegisteredTool = _tools.Get(resolvedToolName);
|
||||
if (globallyRegisteredTool != null &&
|
||||
@@ -1305,6 +1332,28 @@ public partial class AgentLoopService
|
||||
EmitEvent(AgentEventType.ToolCall, effectiveCall.ToolName,
|
||||
FormatToolCallSummary(effectiveCall));
|
||||
|
||||
var toolInputJson = effectiveCall.ToolInput?.ToString() ?? "{}";
|
||||
TrackExplorationToolUse(explorationState, effectiveCall.ToolName, toolInputJson);
|
||||
if (!explorationState.CorrectiveHintInjected &&
|
||||
ShouldInjectExplorationCorrection(explorationState, effectiveCall.ToolName, toolInputJson))
|
||||
{
|
||||
explorationState.CorrectiveHintInjected = true;
|
||||
explorationState.BroadScanDetected = true;
|
||||
explorationState.SelectiveHit = false;
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "system",
|
||||
Content =
|
||||
"Exploration correction: The current request is narrow. Stop broad workspace scanning. " +
|
||||
"Use grep/glob to identify only files directly related to the user's topic, then read a very small targeted set. " +
|
||||
"Do not repeat folder_map unless repository structure is genuinely required."
|
||||
});
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
"",
|
||||
"탐색 범위를 좁히는 중 · 관련 파일만 다시 선택합니다");
|
||||
}
|
||||
|
||||
var decisionTransition = await TryHandleUserDecisionTransitionsAsync(effectiveCall, context, messages);
|
||||
if (!string.IsNullOrEmpty(decisionTransition.TerminalResponse))
|
||||
return decisionTransition.TerminalResponse;
|
||||
@@ -1412,7 +1461,7 @@ public partial class AgentLoopService
|
||||
await RunRuntimeHooksAsync(
|
||||
"__stop_requested__",
|
||||
"post",
|
||||
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }),
|
||||
JsonSerializer.Serialize(new { runId = _currentRunId, tool = effectiveCall.ToolName }, s_jsonOpts),
|
||||
"cancelled",
|
||||
success: false);
|
||||
EmitEvent(AgentEventType.Complete, "", "작업이 중단되었습니다.");
|
||||
@@ -1613,7 +1662,8 @@ public partial class AgentLoopService
|
||||
llm,
|
||||
executionPolicy,
|
||||
context,
|
||||
ct);
|
||||
ct,
|
||||
documentPlanCalled);
|
||||
if (terminalCompleted)
|
||||
{
|
||||
if (consumedExtraIteration)
|
||||
@@ -1664,7 +1714,7 @@ public partial class AgentLoopService
|
||||
await RunRuntimeHooksAsync(
|
||||
"__stop_requested__",
|
||||
"post",
|
||||
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }),
|
||||
JsonSerializer.Serialize(new { runId = _currentRunId, reason = "loop_cancelled" }, s_jsonOpts),
|
||||
"cancelled",
|
||||
success: false);
|
||||
}
|
||||
@@ -1699,6 +1749,20 @@ public partial class AgentLoopService
|
||||
// 통계 기록 (도구 호출이 1회 이상인 세션만)
|
||||
if (totalToolCalls > 0)
|
||||
{
|
||||
AgentPerformanceLogService.LogExplorationBreadth(
|
||||
_conversationId,
|
||||
ActiveTab,
|
||||
new
|
||||
{
|
||||
scope = explorationState.Scope.ToString().ToLowerInvariant(),
|
||||
folder_map_calls = explorationState.FolderMapCalls,
|
||||
total_files_read = explorationState.TotalFilesRead,
|
||||
multi_read_files = explorationState.MultiReadFilesRead,
|
||||
broad_scan = explorationState.BroadScanDetected,
|
||||
selective_hit = explorationState.SelectiveHit,
|
||||
corrective_hint = explorationState.CorrectiveHintInjected
|
||||
});
|
||||
|
||||
var durationMs = (long)(DateTime.Now - statsStart).TotalMilliseconds;
|
||||
|
||||
AgentStatsService.RecordSession(new AgentStatsService.AgentSessionRecord
|
||||
@@ -2985,11 +3049,11 @@ public partial class AgentLoopService
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(message.Content);
|
||||
if (doc.RootElement.TryGetProperty("tool_name", out var toolNameProp))
|
||||
if (doc.RootElement.SafeTryGetProperty("tool_name", out var toolNameProp))
|
||||
{
|
||||
toolName = toolNameProp.GetString() ?? "";
|
||||
if (doc.RootElement.TryGetProperty("content", out var contentProp))
|
||||
content = contentProp.GetString() ?? "";
|
||||
toolName = toolNameProp.SafeGetString() ?? "";
|
||||
if (doc.RootElement.SafeTryGetProperty("content", out var contentProp))
|
||||
content = contentProp.SafeGetString() ?? "";
|
||||
return !string.IsNullOrWhiteSpace(toolName);
|
||||
}
|
||||
}
|
||||
@@ -4092,7 +4156,7 @@ public partial class AgentLoopService
|
||||
contentBlocks.Add(new { type = "text", text = verifyResponse });
|
||||
foreach (var tc in verifyToolCalls)
|
||||
contentBlocks.Add(new { type = "tool_use", id = tc.ToolId, name = tc.ToolName, input = tc.ToolInput });
|
||||
var assistantContent = System.Text.Json.JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks });
|
||||
var assistantContent = JsonSerializer.Serialize(new { _tool_use_blocks = contentBlocks }, s_jsonOpts);
|
||||
var assistantMsg = new ChatMessage { Role = "assistant", Content = assistantContent };
|
||||
messages.Add(assistantMsg);
|
||||
addedMessages.Add(assistantMsg);
|
||||
@@ -4253,9 +4317,9 @@ public partial class AgentLoopService
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (inputElement.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
if (inputElement.SafeTryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = prop.GetString();
|
||||
var value = prop.SafeGetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
@@ -4297,8 +4361,8 @@ public partial class AgentLoopService
|
||||
|
||||
if (normalizedTool.Contains("file_write"))
|
||||
{
|
||||
var content = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
|
||||
? c.GetString() ?? ""
|
||||
var content = input.SafeTryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
|
||||
? c.SafeGetString() ?? ""
|
||||
: "";
|
||||
var truncated = content.Length <= 2000 ? content : content[..2000] + "\n... (truncated)";
|
||||
string? previous = null;
|
||||
@@ -4324,17 +4388,17 @@ public partial class AgentLoopService
|
||||
|
||||
if (normalizedTool.Contains("file_edit"))
|
||||
{
|
||||
if (input.TryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array)
|
||||
if (input.SafeTryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var lines = edits.EnumerateArray()
|
||||
.Take(6)
|
||||
.Select((edit, index) =>
|
||||
{
|
||||
var oldText = edit.TryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
|
||||
? oldElem.GetString() ?? ""
|
||||
var oldText = edit.SafeTryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
|
||||
? oldElem.SafeGetString() ?? ""
|
||||
: "";
|
||||
var newText = edit.TryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
|
||||
? newElem.GetString() ?? ""
|
||||
var newText = edit.SafeTryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
|
||||
? newElem.SafeGetString() ?? ""
|
||||
: "";
|
||||
|
||||
oldText = oldText.Length <= 180 ? oldText : oldText[..180] + "...";
|
||||
@@ -4352,8 +4416,8 @@ public partial class AgentLoopService
|
||||
|
||||
if (normalizedTool.Contains("process") || normalizedTool.Contains("bash") || normalizedTool.Contains("powershell"))
|
||||
{
|
||||
var command = input.TryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
|
||||
? cmd.GetString() ?? target
|
||||
var command = input.SafeTryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
|
||||
? cmd.SafeGetString() ?? target
|
||||
: target;
|
||||
return new PermissionPromptPreview(
|
||||
Kind: "command",
|
||||
@@ -4364,8 +4428,8 @@ public partial class AgentLoopService
|
||||
|
||||
if (normalizedTool.Contains("web") || normalizedTool.Contains("fetch") || normalizedTool.Contains("http"))
|
||||
{
|
||||
var url = input.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
|
||||
? u.GetString() ?? target
|
||||
var url = input.SafeTryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
|
||||
? u.SafeGetString() ?? target
|
||||
: target;
|
||||
return new PermissionPromptPreview(
|
||||
Kind: "web",
|
||||
@@ -4636,10 +4700,25 @@ public partial class AgentLoopService
|
||||
};
|
||||
|
||||
if (Dispatcher != null)
|
||||
Dispatcher(() => { Events.Add(evt); EventOccurred?.Invoke(evt); });
|
||||
{
|
||||
Dispatcher(() =>
|
||||
{
|
||||
// 장시간 세션에서 메모리 무한 성장 방지: 이벤트 500개 초과 시 오래된 것 제거
|
||||
while (Events.Count > 500)
|
||||
Events.RemoveAt(0);
|
||||
Events.Add(evt);
|
||||
EventOccurred?.Invoke(evt);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Events.Add(evt);
|
||||
// Dispatcher가 없으면 UI 바인딩이 없는 상태 — lock으로 스레드 안전 보장
|
||||
lock (Events)
|
||||
{
|
||||
while (Events.Count > 500)
|
||||
Events.RemoveAt(0);
|
||||
Events.Add(evt);
|
||||
}
|
||||
EventOccurred?.Invoke(evt);
|
||||
}
|
||||
}
|
||||
@@ -4655,10 +4734,10 @@ public partial class AgentLoopService
|
||||
// Git 커밋 — 수준에 관계없이 무조건 확인
|
||||
if (toolName == "git_tool")
|
||||
{
|
||||
var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : "";
|
||||
var action = input?.SafeTryGetProperty("action", out var a) == true ? a.SafeGetString() : "";
|
||||
if (action == "commit")
|
||||
{
|
||||
var msg = input?.TryGetProperty("args", out var m) == true ? m.GetString() : "";
|
||||
var msg = input?.SafeTryGetProperty("args", out var m) == true ? m.SafeGetString() : "";
|
||||
return $"Git 커밋을 실행하시겠습니까?\n\n커밋 메시지: {msg}";
|
||||
}
|
||||
}
|
||||
@@ -4669,7 +4748,7 @@ public partial class AgentLoopService
|
||||
// process 도구 (외부 명령 실행)
|
||||
if (toolName == "process")
|
||||
{
|
||||
var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : "";
|
||||
var cmd = input?.SafeTryGetProperty("command", out var c) == true ? c.SafeGetString() : "";
|
||||
return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
|
||||
}
|
||||
return null;
|
||||
@@ -4681,14 +4760,14 @@ public partial class AgentLoopService
|
||||
// 외부 명령 실행
|
||||
if (toolName == "process")
|
||||
{
|
||||
var cmd = input?.TryGetProperty("command", out var c) == true ? c.GetString() : "";
|
||||
var cmd = input?.SafeTryGetProperty("command", out var c) == true ? c.SafeGetString() : "";
|
||||
return $"외부 명령을 실행하시겠습니까?\n\n명령: {cmd}";
|
||||
}
|
||||
|
||||
// 새 파일 생성
|
||||
if (toolName == "file_write")
|
||||
{
|
||||
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
|
||||
var path = input?.SafeTryGetProperty("file_path", out var p) == true ? p.SafeGetString() : "";
|
||||
if (!string.IsNullOrEmpty(path))
|
||||
{
|
||||
var fullPath = System.IO.Path.IsPathRooted(path) ? path
|
||||
@@ -4706,15 +4785,15 @@ public partial class AgentLoopService
|
||||
if (string.Equals(ActiveTab, "Cowork", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
// "path" 파라미터 우선, 없으면 "file_path" 순으로 추출
|
||||
var path = (input?.TryGetProperty("path", out var p1) == true ? p1.GetString() : null)
|
||||
?? (input?.TryGetProperty("file_path", out var p2) == true ? p2.GetString() : "");
|
||||
var path = (input?.SafeTryGetProperty("path", out var p1) == true ? p1.SafeGetString() : null)
|
||||
?? (input?.SafeTryGetProperty("file_path", out var p2) == true ? p2.SafeGetString() : "");
|
||||
return $"문서를 생성하시겠습니까?\n\n도구: {toolName}\n경로: {path}";
|
||||
}
|
||||
|
||||
// 빌드/테스트 실행
|
||||
if (toolName is "build_run" or "test_loop")
|
||||
{
|
||||
var action = input?.TryGetProperty("action", out var a) == true ? a.GetString() : "";
|
||||
var action = input?.SafeTryGetProperty("action", out var a) == true ? a.SafeGetString() : "";
|
||||
return $"빌드/테스트를 실행하시겠습니까?\n\n도구: {toolName}\n액션: {action}";
|
||||
}
|
||||
}
|
||||
@@ -4724,7 +4803,7 @@ public partial class AgentLoopService
|
||||
{
|
||||
if (toolName is "file_write" or "file_edit")
|
||||
{
|
||||
var path = input?.TryGetProperty("file_path", out var p) == true ? p.GetString() : "";
|
||||
var path = input?.SafeTryGetProperty("file_path", out var p) == true ? p.SafeGetString() : "";
|
||||
return $"파일을 수정하시겠습니까?\n\n경로: {path}";
|
||||
}
|
||||
}
|
||||
@@ -4739,12 +4818,12 @@ public partial class AgentLoopService
|
||||
{
|
||||
// 주요 파라미터만 표시
|
||||
var input = call.ToolInput.Value;
|
||||
if (input.TryGetProperty("path", out var path))
|
||||
return $"{call.ToolName}: {path.GetString()}";
|
||||
if (input.TryGetProperty("command", out var cmd))
|
||||
return $"{call.ToolName}: {cmd.GetString()}";
|
||||
if (input.TryGetProperty("pattern", out var pat))
|
||||
return $"{call.ToolName}: {pat.GetString()}";
|
||||
if (input.SafeTryGetProperty("path", out var path))
|
||||
return $"{call.ToolName}: {path.SafeGetString()}";
|
||||
if (input.SafeTryGetProperty("command", out var cmd))
|
||||
return $"{call.ToolName}: {cmd.SafeGetString()}";
|
||||
if (input.SafeTryGetProperty("pattern", out var pat))
|
||||
return $"{call.ToolName}: {pat.SafeGetString()}";
|
||||
return call.ToolName;
|
||||
}
|
||||
catch { return call.ToolName; }
|
||||
|
||||
@@ -103,11 +103,17 @@ public partial class AgentLoopService
|
||||
Models.LlmSettings llm,
|
||||
ModelExecutionProfileCatalog.ExecutionPolicy executionPolicy,
|
||||
AgentContext context,
|
||||
CancellationToken ct)
|
||||
CancellationToken ct,
|
||||
bool documentPlanWasCalled = false)
|
||||
{
|
||||
if (!result.Success || !IsTerminalDocumentTool(call.ToolName) || toolCalls.Count != 1)
|
||||
return (false, false);
|
||||
|
||||
// document_plan 없이 바로 문서 도구가 호출된 경우 — 아직 LLM이 추가 반복을 할 수 있음.
|
||||
// 한 번에 생성된 문서는 내용이 부실할 수 있으므로 조기 종료하지 않고 LLM에 판단을 맡긴다.
|
||||
if (!_docFallbackAttempted && !documentPlanWasCalled)
|
||||
return (false, false);
|
||||
|
||||
var verificationEnabled = executionPolicy.EnablePostToolVerification
|
||||
&& AgentTabSettingsResolver.IsPostToolVerificationEnabled(ActiveTab, llm);
|
||||
var shouldVerify = ShouldRunPostToolVerification(
|
||||
|
||||
@@ -42,8 +42,8 @@ public class Base64Tool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var text = args.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
var text = args.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -69,9 +69,9 @@ public class Base64Tool : IAgentTool
|
||||
|
||||
private static ToolResult EncodeFile(JsonElement args, AgentContext context)
|
||||
{
|
||||
if (!args.TryGetProperty("file_path", out var fp))
|
||||
if (!args.SafeTryGetProperty("file_path", out var fp))
|
||||
return ToolResult.Fail("'file_path' is required for b64file action");
|
||||
var path = fp.GetString() ?? "";
|
||||
var path = fp.SafeGetString() ?? "";
|
||||
if (!Path.IsPathRooted(path)) path = Path.Combine(context.WorkFolder, path);
|
||||
if (!File.Exists(path)) return ToolResult.Fail($"File not found: {path}");
|
||||
|
||||
|
||||
@@ -42,9 +42,9 @@ public class BatchSkill : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var content = args.GetProperty("content").GetString() ?? "";
|
||||
var desc = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
|
||||
var path = args.GetProperty("path").SafeGetString() ?? "";
|
||||
var content = args.GetProperty("content").SafeGetString() ?? "";
|
||||
var desc = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
|
||||
@@ -56,9 +56,9 @@ public class BuildRunTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "detect";
|
||||
var customCmd = args.TryGetProperty("command", out var cmd) ? cmd.GetString() ?? "" : "";
|
||||
var subPath = args.TryGetProperty("project_path", out var pp) ? pp.GetString() ?? "" : "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "detect";
|
||||
var customCmd = args.SafeTryGetProperty("command", out var cmd) ? cmd.SafeGetString() ?? "" : "";
|
||||
var subPath = args.SafeTryGetProperty("project_path", out var pp) ? pp.SafeGetString() ?? "" : "";
|
||||
|
||||
var workDir = context.WorkFolder;
|
||||
if (!string.IsNullOrEmpty(subPath))
|
||||
|
||||
@@ -56,12 +56,12 @@ public class ChartSkill : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var title = args.GetProperty("title").GetString() ?? "Chart";
|
||||
var title = args.GetProperty("title").SafeGetString() ?? "Chart";
|
||||
string path;
|
||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
||||
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||
{
|
||||
path = pathEl.GetString()!;
|
||||
path = pathEl.SafeGetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -69,12 +69,12 @@ public class ChartSkill : IAgentTool
|
||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||
path = (string.IsNullOrWhiteSpace(safe) ? "chart" : safe) + ".html";
|
||||
}
|
||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "dashboard" : "dashboard";
|
||||
var layout = args.TryGetProperty("layout", out var l) ? l.GetString() ?? "single" : "single";
|
||||
var globalAccent = args.TryGetProperty("accent_color", out var ga) ? ga.GetString() : null;
|
||||
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "dashboard" : "dashboard";
|
||||
var layout = args.SafeTryGetProperty("layout", out var l) ? l.SafeGetString() ?? "single" : "single";
|
||||
var globalAccent = args.SafeTryGetProperty("accent_color", out var ga) ? ga.SafeGetString() : null;
|
||||
var globalShowValues = true;
|
||||
if (args.TryGetProperty("show_values", out var sv))
|
||||
globalShowValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.GetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||
if (args.SafeTryGetProperty("show_values", out var sv))
|
||||
globalShowValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
@@ -89,7 +89,7 @@ public class ChartSkill : IAgentTool
|
||||
var dir = Path.GetDirectoryName(fullPath);
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
if (!args.TryGetProperty("charts", out var chartsEl) || chartsEl.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty("charts", out var chartsEl) || chartsEl.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("charts 파라미터가 필요합니다 (배열 형식).");
|
||||
|
||||
var chartCount = chartsEl.GetArrayLength();
|
||||
@@ -138,17 +138,17 @@ public class ChartSkill : IAgentTool
|
||||
|
||||
private string RenderChart(JsonElement chart, int idx, string? globalAccent, bool globalShowValues)
|
||||
{
|
||||
var type = chart.TryGetProperty("type", out var t) ? t.GetString() ?? "bar" : "bar";
|
||||
var chartTitle = chart.TryGetProperty("title", out var ct) ? ct.GetString() ?? "" : "";
|
||||
var unit = chart.TryGetProperty("unit", out var u) ? u.GetString() ?? "" : "";
|
||||
var type = chart.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "bar" : "bar";
|
||||
var chartTitle = chart.SafeTryGetProperty("title", out var ct) ? ct.SafeGetString() ?? "" : "";
|
||||
var unit = chart.SafeTryGetProperty("unit", out var u) ? u.SafeGetString() ?? "" : "";
|
||||
var labels = ParseStringArray(chart, "labels");
|
||||
var datasets = ParseDatasets(chart);
|
||||
|
||||
// Per-chart overrides
|
||||
var accentColor = chart.TryGetProperty("accent_color", out var ac) ? ac.GetString() : globalAccent;
|
||||
var accentColor = chart.SafeTryGetProperty("accent_color", out var ac) ? ac.SafeGetString() : globalAccent;
|
||||
var showValues = globalShowValues;
|
||||
if (chart.TryGetProperty("show_values", out var sv))
|
||||
showValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.GetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||
if (chart.SafeTryGetProperty("show_values", out var sv))
|
||||
showValues = sv.ValueKind != JsonValueKind.False && !string.Equals(sv.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Apply accent color to first dataset if single-color context
|
||||
if (!string.IsNullOrEmpty(accentColor) && datasets.Count > 0 && datasets[0].Color == Palette[0])
|
||||
@@ -534,17 +534,17 @@ public class ChartSkill : IAgentTool
|
||||
foreach (var ds in datasets)
|
||||
{
|
||||
var pts = new List<(double x, double y)>();
|
||||
if (chart.TryGetProperty("datasets", out var dsArr) && dsArr.ValueKind == JsonValueKind.Array)
|
||||
if (chart.SafeTryGetProperty("datasets", out var dsArr) && dsArr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var dEl in dsArr.EnumerateArray())
|
||||
{
|
||||
var dName = dEl.TryGetProperty("name", out var nn) ? nn.GetString() : null;
|
||||
var dName = dEl.SafeTryGetProperty("name", out var nn) ? nn.SafeGetString() : null;
|
||||
if (dName != ds.Name) continue;
|
||||
if (dEl.TryGetProperty("points", out var ptsEl) && ptsEl.ValueKind == JsonValueKind.Array)
|
||||
if (dEl.SafeTryGetProperty("points", out var ptsEl) && ptsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var pEl in ptsEl.EnumerateArray())
|
||||
{
|
||||
if (pEl.TryGetProperty("x", out var px) && pEl.TryGetProperty("y", out var py)
|
||||
if (pEl.SafeTryGetProperty("x", out var px) && pEl.SafeTryGetProperty("y", out var py)
|
||||
&& px.TryGetDouble(out var xd) && py.TryGetDouble(out var yd))
|
||||
pts.Add((xd, yd));
|
||||
}
|
||||
@@ -609,12 +609,12 @@ public class ChartSkill : IAgentTool
|
||||
private static string RenderHeatmap(JsonElement chart, List<string> xLabels)
|
||||
{
|
||||
var yLabels = ParseStringArray(chart, "y_labels");
|
||||
var colorFrom = chart.TryGetProperty("color_from", out var cf) ? cf.GetString() ?? "#EFF6FF" : "#EFF6FF";
|
||||
var colorTo = chart.TryGetProperty("color_to", out var ct2) ? ct2.GetString() ?? "#1D4ED8" : "#1D4ED8";
|
||||
var colorFrom = chart.SafeTryGetProperty("color_from", out var cf) ? cf.SafeGetString() ?? "#EFF6FF" : "#EFF6FF";
|
||||
var colorTo = chart.SafeTryGetProperty("color_to", out var ct2) ? ct2.SafeGetString() ?? "#1D4ED8" : "#1D4ED8";
|
||||
|
||||
// Parse 2D values array
|
||||
var grid = new List<List<double>>();
|
||||
if (chart.TryGetProperty("values", out var valsEl) && valsEl.ValueKind == JsonValueKind.Array)
|
||||
if (chart.SafeTryGetProperty("values", out var valsEl) && valsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var row in valsEl.EnumerateArray())
|
||||
{
|
||||
@@ -678,19 +678,19 @@ public class ChartSkill : IAgentTool
|
||||
|
||||
private static string RenderGauge(JsonElement chart, string unit)
|
||||
{
|
||||
var value = chart.TryGetProperty("value", out var vEl) && vEl.TryGetDouble(out var vd) ? vd : 0;
|
||||
var min = chart.TryGetProperty("min", out var minEl) && minEl.TryGetDouble(out var mind) ? mind : 0;
|
||||
var max = chart.TryGetProperty("max", out var maxEl) && maxEl.TryGetDouble(out var maxd) ? maxd : 100;
|
||||
var value = chart.SafeTryGetProperty("value", out var vEl) && vEl.TryGetDouble(out var vd) ? vd : 0;
|
||||
var min = chart.SafeTryGetProperty("min", out var minEl) && minEl.TryGetDouble(out var mind) ? mind : 0;
|
||||
var max = chart.SafeTryGetProperty("max", out var maxEl) && maxEl.TryGetDouble(out var maxd) ? maxd : 100;
|
||||
if (max <= min) max = min + 1;
|
||||
|
||||
// Determine arc color from thresholds (highest threshold below value wins)
|
||||
var arcColor = Palette[0];
|
||||
if (chart.TryGetProperty("thresholds", out var thEl) && thEl.ValueKind == JsonValueKind.Array)
|
||||
if (chart.SafeTryGetProperty("thresholds", out var thEl) && thEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var thresholds = thEl.EnumerateArray()
|
||||
.Select(t => (
|
||||
At: t.TryGetProperty("at", out var a) && a.TryGetDouble(out var ad) ? ad : 0,
|
||||
Color: t.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[0] : Palette[0]))
|
||||
At: t.SafeTryGetProperty("at", out var a) && a.TryGetDouble(out var ad) ? ad : 0,
|
||||
Color: t.SafeTryGetProperty("color", out var c) ? c.SafeGetString() ?? Palette[0] : Palette[0]))
|
||||
.OrderBy(t => t.At)
|
||||
.ToList();
|
||||
foreach (var (at, color) in thresholds)
|
||||
@@ -754,16 +754,16 @@ public class ChartSkill : IAgentTool
|
||||
|
||||
private static List<string> ParseStringArray(JsonElement parent, string prop)
|
||||
{
|
||||
if (!parent.TryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
if (!parent.SafeTryGetProperty(prop, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return new();
|
||||
return arr.EnumerateArray().Select(e => e.GetString() ?? "").ToList();
|
||||
return arr.EnumerateArray().Select(e => e.SafeGetString() ?? "").ToList();
|
||||
}
|
||||
|
||||
private List<Dataset> ParseDatasets(JsonElement chart)
|
||||
{
|
||||
if (!chart.TryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array)
|
||||
if (!chart.SafeTryGetProperty("datasets", out var dsArr) || dsArr.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
if (chart.TryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array)
|
||||
if (chart.SafeTryGetProperty("values", out var vals) && vals.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
@@ -782,10 +782,10 @@ public class ChartSkill : IAgentTool
|
||||
int colorIdx = 0;
|
||||
foreach (var ds in dsArr.EnumerateArray())
|
||||
{
|
||||
var name = ds.TryGetProperty("name", out var n) ? n.GetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}";
|
||||
var color = ds.TryGetProperty("color", out var c) ? c.GetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length];
|
||||
var name = ds.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? $"Series{colorIdx + 1}" : $"Series{colorIdx + 1}";
|
||||
var color = ds.SafeTryGetProperty("color", out var c) ? c.SafeGetString() ?? Palette[colorIdx % Palette.Length] : Palette[colorIdx % Palette.Length];
|
||||
var values = new List<double>();
|
||||
if (ds.TryGetProperty("values", out var v) && v.ValueKind == JsonValueKind.Array)
|
||||
if (ds.SafeTryGetProperty("values", out var v) && v.ValueKind == JsonValueKind.Array)
|
||||
values = v.EnumerateArray().Select(e => e.TryGetDouble(out var d) ? d : 0).ToList();
|
||||
list.Add(new Dataset { Name = name, Values = values, Color = color });
|
||||
colorIdx++;
|
||||
|
||||
@@ -66,9 +66,9 @@ public class CheckpointTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("action", out var actionEl))
|
||||
if (!args.SafeTryGetProperty("action", out var actionEl))
|
||||
return ToolResult.Fail("action이 필요합니다.");
|
||||
var action = actionEl.GetString() ?? "";
|
||||
var action = actionEl.SafeGetString() ?? "";
|
||||
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
||||
@@ -87,7 +87,7 @@ public class CheckpointTool : IAgentTool
|
||||
|
||||
private async Task<ToolResult> CreateCheckpoint(JsonElement args, AgentContext context, string checkpointDir, CancellationToken ct)
|
||||
{
|
||||
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "unnamed" : "unnamed";
|
||||
var name = args.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "unnamed" : "unnamed";
|
||||
// 이름에서 파일 시스템 비안전 문자 제거
|
||||
name = string.Join("_", name.Split(Path.GetInvalidFileNameChars()));
|
||||
|
||||
@@ -206,16 +206,16 @@ public class CheckpointTool : IAgentTool
|
||||
// ID 또는 이름으로 체크포인트 찾기
|
||||
string? targetDir = null;
|
||||
|
||||
if (args.TryGetProperty("id", out var idEl))
|
||||
if (args.SafeTryGetProperty("id", out var idEl))
|
||||
{
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
|
||||
if (id >= 0 && id < dirs.Count)
|
||||
targetDir = dirs[id];
|
||||
}
|
||||
|
||||
if (targetDir == null && args.TryGetProperty("name", out var nameEl))
|
||||
if (targetDir == null && args.SafeTryGetProperty("name", out var nameEl))
|
||||
{
|
||||
var name = nameEl.GetString() ?? "";
|
||||
var name = nameEl.SafeGetString() ?? "";
|
||||
targetDir = dirs.FirstOrDefault(d => Path.GetFileName(d).Contains(name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
@@ -276,10 +276,10 @@ public class CheckpointTool : IAgentTool
|
||||
.OrderByDescending(d => d)
|
||||
.ToList();
|
||||
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
if (!args.SafeTryGetProperty("id", out var idEl))
|
||||
return ToolResult.Fail("삭제할 체크포인트 id가 필요합니다.");
|
||||
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
|
||||
if (id < 0 || id >= dirs.Count)
|
||||
return ToolResult.Fail($"잘못된 체크포인트 ID: {id}. 0~{dirs.Count - 1} 범위를 사용하세요.");
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ public class ClipboardTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -79,10 +79,10 @@ public class ClipboardTool : IAgentTool
|
||||
|
||||
private static ToolResult WriteClipboard(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("text", out var textProp))
|
||||
if (!args.SafeTryGetProperty("text", out var textProp))
|
||||
return ToolResult.Fail("'text' parameter is required for write action");
|
||||
|
||||
var text = textProp.GetString() ?? "";
|
||||
var text = textProp.SafeGetString() ?? "";
|
||||
Clipboard.SetText(text);
|
||||
return ToolResult.Ok($"✓ Clipboard updated ({text.Length} chars)");
|
||||
}
|
||||
|
||||
@@ -53,9 +53,9 @@ public class CodeReviewTool : IAgentTool
|
||||
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeReview ?? true))
|
||||
return ToolResult.Ok("코드 리뷰가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
||||
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
var target = args.TryGetProperty("target", out var t) ? t.GetString() ?? "" : "";
|
||||
var focus = args.TryGetProperty("focus", out var f) ? f.GetString() ?? "all" : "all";
|
||||
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
|
||||
var target = args.SafeTryGetProperty("target", out var t) ? t.SafeGetString() ?? "" : "";
|
||||
var focus = args.SafeTryGetProperty("focus", out var f) ? f.SafeGetString() ?? "all" : "all";
|
||||
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
||||
|
||||
@@ -50,9 +50,9 @@ public class CodeSearchTool : IAgentTool
|
||||
if (!(app?.SettingsService?.Settings.Llm.Code.EnableCodeIndex ?? true))
|
||||
return ToolResult.Ok("코드 시맨틱 검색이 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
||||
|
||||
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
|
||||
var maxResults = args.TryGetProperty("max_results", out var m) ? m.GetInt32() : 5;
|
||||
var reindex = args.TryGetProperty("reindex", out var ri) && ri.GetBoolean();
|
||||
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
|
||||
var maxResults = args.SafeTryGetProperty("max_results", out var m) ? m.GetInt32() : 5;
|
||||
var reindex = args.SafeTryGetProperty("reindex", out var ri) && ri.GetBoolean();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return ToolResult.Fail("query가 필요합니다.");
|
||||
|
||||
@@ -21,9 +21,9 @@ public sealed class CronCreateTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var name = args.TryGetProperty("name", out var n) ? (n.GetString() ?? "").Trim() : "";
|
||||
var schedule = args.TryGetProperty("schedule", out var s) ? (s.GetString() ?? "").Trim() : "";
|
||||
var command = args.TryGetProperty("command", out var c) ? (c.GetString() ?? "").Trim() : "";
|
||||
var name = args.SafeTryGetProperty("name", out var n) ? (n.SafeGetString() ?? "").Trim() : "";
|
||||
var schedule = args.SafeTryGetProperty("schedule", out var s) ? (s.SafeGetString() ?? "").Trim() : "";
|
||||
var command = args.SafeTryGetProperty("command", out var c) ? (c.SafeGetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(schedule) || string.IsNullOrWhiteSpace(command))
|
||||
return Task.FromResult(ToolResult.Fail("name, schedule, command are required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
|
||||
@@ -23,8 +23,8 @@ public sealed class CronDeleteTool : IAgentTool
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
|
||||
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
|
||||
var id = args.SafeTryGetProperty("id", out var idEl) ? (idEl.SafeGetString() ?? "").Trim() : "";
|
||||
var name = args.SafeTryGetProperty("name", out var nameEl) ? (nameEl.SafeGetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
|
||||
return Task.FromResult(ToolResult.Fail("id or name is required."));
|
||||
|
||||
|
||||
@@ -42,31 +42,31 @@ public class CsvSkill : IAgentTool
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
// ── 필수 파라미터 검증 ──────────────────────────────────────────────
|
||||
if (!args.TryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("필수 파라미터 누락: 'headers' (문자열 배열)가 필요합니다.");
|
||||
if (!args.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("필수 파라미터 누락: 'rows' (배열의 배열)가 필요합니다.");
|
||||
|
||||
// path 미제공 시 첫 번째 헤더로 파일명 자동 생성
|
||||
string path;
|
||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
||||
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||
{
|
||||
path = pathEl.GetString()!;
|
||||
path = pathEl.SafeGetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
var hint = headersEl.GetArrayLength() > 0
|
||||
? headersEl[0].GetString() ?? "data"
|
||||
var hint = headersEl.GetArrayLength() > 0 && headersEl[0].ValueKind == JsonValueKind.String
|
||||
? headersEl[0].SafeGetString() ?? "data"
|
||||
: "data";
|
||||
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
||||
if (safe.Length > 40) safe = safe[..40].TrimEnd();
|
||||
path = (string.IsNullOrWhiteSpace(safe) ? "data" : safe) + ".csv";
|
||||
}
|
||||
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
|
||||
var delimiterKey = args.TryGetProperty("delimiter", out var delimEl) ? delimEl.GetString() ?? "comma" : "comma";
|
||||
var useBom = !args.TryGetProperty("bom", out var bomEl) || bomEl.ValueKind != JsonValueKind.False; // default true
|
||||
var useSummary = args.TryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.True;
|
||||
var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
|
||||
var delimiterKey = args.SafeTryGetProperty("delimiter", out var delimEl) ? delimEl.SafeGetString() ?? "comma" : "comma";
|
||||
var useBom = !args.SafeTryGetProperty("bom", out var bomEl) || bomEl.ValueKind != JsonValueKind.False; // default true
|
||||
var useSummary = args.SafeTryGetProperty("summary", out var sumEl) && sumEl.ValueKind == JsonValueKind.True;
|
||||
|
||||
// ── 구분자 해석 ────────────────────────────────────────────────────
|
||||
var delimiter = delimiterKey.ToLowerInvariant() switch
|
||||
@@ -78,9 +78,9 @@ public class CsvSkill : IAgentTool
|
||||
|
||||
// ── 열 타입 힌트 ───────────────────────────────────────────────────
|
||||
var colTypeHints = new List<string>();
|
||||
if (args.TryGetProperty("col_types", out var colTypesEl) && colTypesEl.ValueKind == JsonValueKind.Array)
|
||||
if (args.SafeTryGetProperty("col_types", out var colTypesEl) && colTypesEl.ValueKind == JsonValueKind.Array)
|
||||
foreach (var ct2 in colTypesEl.EnumerateArray())
|
||||
colTypeHints.Add(ct2.GetString()?.ToLowerInvariant() ?? "text");
|
||||
colTypeHints.Add(ct2.SafeGetString()?.ToLowerInvariant() ?? "text");
|
||||
|
||||
// ── 경로 처리 ──────────────────────────────────────────────────────
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
@@ -118,7 +118,7 @@ public class CsvSkill : IAgentTool
|
||||
// ── 헤더 수집 ──────────────────────────────────────────────────
|
||||
var headerList = new List<string>();
|
||||
foreach (var h in headersEl.EnumerateArray())
|
||||
headerList.Add(h.GetString() ?? "");
|
||||
headerList.Add(h.SafeGetString() ?? "");
|
||||
|
||||
int colCount = headerList.Count;
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ public class DataPivotTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var sourcePath = args.GetProperty("source_path").GetString() ?? "";
|
||||
var sourcePath = args.GetProperty("source_path").SafeGetString() ?? "";
|
||||
var fullPath = FileReadTool.ResolvePath(sourcePath, context.WorkFolder);
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
@@ -81,28 +81,28 @@ public class DataPivotTool : IAgentTool
|
||||
var originalCount = data.Count;
|
||||
|
||||
// 필터 적용
|
||||
if (args.TryGetProperty("filter", out var filterEl))
|
||||
if (args.SafeTryGetProperty("filter", out var filterEl))
|
||||
{
|
||||
var filterStr = filterEl.GetString() ?? "";
|
||||
var filterStr = filterEl.SafeGetString() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(filterStr))
|
||||
data = ApplyFilter(data, filterStr);
|
||||
}
|
||||
|
||||
// 그룹화 & 집계
|
||||
List<Dictionary<string, string>> result;
|
||||
if (args.TryGetProperty("group_by", out var groupEl) && groupEl.ValueKind == JsonValueKind.Array)
|
||||
if (args.SafeTryGetProperty("group_by", out var groupEl) && groupEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var groupCols = new List<string>();
|
||||
foreach (var g in groupEl.EnumerateArray())
|
||||
groupCols.Add(g.GetString() ?? "");
|
||||
groupCols.Add(g.SafeGetString() ?? "");
|
||||
|
||||
var aggregates = new List<(string Column, string Function)>();
|
||||
if (args.TryGetProperty("aggregates", out var aggEl) && aggEl.ValueKind == JsonValueKind.Array)
|
||||
if (args.SafeTryGetProperty("aggregates", out var aggEl) && aggEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var agg in aggEl.EnumerateArray())
|
||||
{
|
||||
var col = agg.TryGetProperty("column", out var c) ? c.GetString() ?? "" : "";
|
||||
var func = agg.TryGetProperty("function", out var f) ? f.GetString() ?? "count" : "count";
|
||||
var col = agg.SafeTryGetProperty("column", out var c) ? c.SafeGetString() ?? "" : "";
|
||||
var func = agg.SafeTryGetProperty("function", out var f) ? f.SafeGetString() ?? "count" : "count";
|
||||
if (!string.IsNullOrEmpty(col))
|
||||
aggregates.Add((col, func));
|
||||
}
|
||||
@@ -116,19 +116,19 @@ public class DataPivotTool : IAgentTool
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (args.TryGetProperty("sort_by", out var sortEl))
|
||||
if (args.SafeTryGetProperty("sort_by", out var sortEl))
|
||||
{
|
||||
var sortBy = sortEl.GetString() ?? "";
|
||||
var sortBy = sortEl.SafeGetString() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(sortBy))
|
||||
result = ApplySort(result, sortBy);
|
||||
}
|
||||
|
||||
// Top N
|
||||
if (args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var topN) && topN > 0)
|
||||
if (args.SafeTryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var topN) && topN > 0)
|
||||
result = result.Take(topN).ToList();
|
||||
|
||||
// 출력 포맷
|
||||
var outputFormat = args.TryGetProperty("output_format", out var ofmt) ? ofmt.GetString() ?? "table" : "table";
|
||||
var outputFormat = args.SafeTryGetProperty("output_format", out var ofmt) ? ofmt.SafeGetString() ?? "table" : "table";
|
||||
var output = FormatOutput(result, outputFormat);
|
||||
|
||||
return Task.FromResult(ToolResult.Ok(
|
||||
@@ -192,7 +192,7 @@ public class DataPivotTool : IAgentTool
|
||||
|
||||
var arr = doc.RootElement.ValueKind == JsonValueKind.Array
|
||||
? doc.RootElement
|
||||
: doc.RootElement.TryGetProperty("data", out var d) ? d : doc.RootElement;
|
||||
: doc.RootElement.SafeTryGetProperty("data", out var d) ? d : doc.RootElement;
|
||||
|
||||
if (arr.ValueKind != JsonValueKind.Array) return data;
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ public class DateTimeTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -94,7 +94,7 @@ public class DateTimeTool : IAgentTool
|
||||
|
||||
private static ToolResult Parse(JsonElement args)
|
||||
{
|
||||
var dateStr = args.TryGetProperty("date", out var d) ? d.GetString() ?? "" : "";
|
||||
var dateStr = args.SafeTryGetProperty("date", out var d) ? d.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
|
||||
|
||||
if (!DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dt) &&
|
||||
@@ -110,8 +110,8 @@ public class DateTimeTool : IAgentTool
|
||||
|
||||
private static ToolResult Diff(JsonElement args)
|
||||
{
|
||||
var d1 = args.TryGetProperty("date", out var v1) ? v1.GetString() ?? "" : "";
|
||||
var d2 = args.TryGetProperty("date2", out var v2) ? v2.GetString() ?? "" : "";
|
||||
var d1 = args.SafeTryGetProperty("date", out var v1) ? v1.SafeGetString() ?? "" : "";
|
||||
var d2 = args.SafeTryGetProperty("date2", out var v2) ? v2.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(d1) || string.IsNullOrEmpty(d2))
|
||||
return ToolResult.Fail("'date' and 'date2' parameters are required");
|
||||
|
||||
@@ -132,9 +132,9 @@ public class DateTimeTool : IAgentTool
|
||||
|
||||
private static ToolResult Add(JsonElement args)
|
||||
{
|
||||
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
|
||||
var amountStr = args.TryGetProperty("amount", out var av) ? av.GetString() ?? "0" : "0";
|
||||
var unit = args.TryGetProperty("unit", out var uv) ? uv.GetString() ?? "days" : "days";
|
||||
var dateStr = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
|
||||
var amountStr = args.SafeTryGetProperty("amount", out var av) ? av.SafeGetString() ?? "0" : "0";
|
||||
var unit = args.SafeTryGetProperty("unit", out var uv) ? uv.SafeGetString() ?? "days" : "days";
|
||||
|
||||
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
|
||||
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
|
||||
@@ -159,7 +159,7 @@ public class DateTimeTool : IAgentTool
|
||||
|
||||
private static ToolResult Epoch(JsonElement args)
|
||||
{
|
||||
var input = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
|
||||
var input = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(input)) return ToolResult.Fail("'date' parameter is required");
|
||||
|
||||
// 숫자면 epoch → datetime
|
||||
@@ -186,8 +186,8 @@ public class DateTimeTool : IAgentTool
|
||||
|
||||
private static ToolResult FormatDate(JsonElement args)
|
||||
{
|
||||
var dateStr = args.TryGetProperty("date", out var dv) ? dv.GetString() ?? "" : "";
|
||||
var pattern = args.TryGetProperty("pattern", out var pv) ? pv.GetString() ?? "yyyy-MM-dd" : "yyyy-MM-dd";
|
||||
var dateStr = args.SafeTryGetProperty("date", out var dv) ? dv.SafeGetString() ?? "" : "";
|
||||
var pattern = args.SafeTryGetProperty("pattern", out var pv) ? pv.SafeGetString() ?? "yyyy-MM-dd" : "yyyy-MM-dd";
|
||||
|
||||
if (string.IsNullOrEmpty(dateStr)) return ToolResult.Fail("'date' parameter is required");
|
||||
if (!DateTime.TryParse(dateStr, out var dt)) return ToolResult.Fail($"Cannot parse date: '{dateStr}'");
|
||||
|
||||
@@ -40,7 +40,7 @@ public class DevEnvDetectTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var category = args.TryGetProperty("category", out var cat) ? cat.GetString() ?? "all" : "all";
|
||||
var category = args.SafeTryGetProperty("category", out var cat) ? cat.SafeGetString() ?? "all" : "all";
|
||||
|
||||
// 캐시 확인
|
||||
if (_cache.HasValue && (DateTime.UtcNow - _cache.Value.Time).TotalSeconds < 60)
|
||||
|
||||
@@ -42,9 +42,9 @@ public class DiffPreviewTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
||||
var newContent = args.GetProperty("new_content").GetString() ?? "";
|
||||
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
|
||||
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
|
||||
var newContent = args.GetProperty("new_content").SafeGetString() ?? "";
|
||||
var description = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
|
||||
|
||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||
|
||||
|
||||
@@ -50,11 +50,11 @@ public class DiffTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var mode = args.GetProperty("mode").GetString() ?? "text";
|
||||
var left = args.GetProperty("left").GetString() ?? "";
|
||||
var right = args.GetProperty("right").GetString() ?? "";
|
||||
var leftLabel = args.TryGetProperty("left_label", out var ll) ? ll.GetString() ?? "left" : "left";
|
||||
var rightLabel = args.TryGetProperty("right_label", out var rl) ? rl.GetString() ?? "right" : "right";
|
||||
var mode = args.GetProperty("mode").SafeGetString() ?? "text";
|
||||
var left = args.GetProperty("left").SafeGetString() ?? "";
|
||||
var right = args.GetProperty("right").SafeGetString() ?? "";
|
||||
var leftLabel = args.SafeTryGetProperty("left_label", out var ll) ? ll.SafeGetString() ?? "left" : "left";
|
||||
var rightLabel = args.SafeTryGetProperty("right_label", out var rl) ? rl.SafeGetString() ?? "right" : "right";
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -64,24 +64,24 @@ public class DocumentAssemblerTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var title = args.GetProperty("title").GetString() ?? "Document";
|
||||
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
|
||||
var requestedMood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? GetDefaultMood() : GetDefaultMood();
|
||||
var useToc = !args.TryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
|
||||
var coverSubtitle = args.TryGetProperty("cover_subtitle", out var cs) ? cs.GetString() : null;
|
||||
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
|
||||
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
|
||||
var path = args.GetProperty("path").SafeGetString() ?? "";
|
||||
var title = args.GetProperty("title").SafeGetString() ?? "Document";
|
||||
var requestedFormat = args.SafeTryGetProperty("format", out var fmt) ? fmt.SafeGetString() ?? "auto" : GetDefaultOutputFormat();
|
||||
var requestedMood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? GetDefaultMood() : GetDefaultMood();
|
||||
var useToc = !args.SafeTryGetProperty("toc", out var tocVal) || tocVal.GetBoolean(); // default true
|
||||
var coverSubtitle = args.SafeTryGetProperty("cover_subtitle", out var cs) ? cs.SafeGetString() : null;
|
||||
var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null;
|
||||
var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null;
|
||||
|
||||
if (!args.TryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty("sections", out var sectionsEl) || sectionsEl.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("sections 배열이 필요합니다.");
|
||||
|
||||
var sections = new List<(string Heading, string Content, int Level)>();
|
||||
foreach (var sec in sectionsEl.EnumerateArray())
|
||||
{
|
||||
var heading = sec.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
|
||||
var content = sec.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
var level = sec.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
|
||||
var heading = sec.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
|
||||
var content = sec.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
|
||||
var level = sec.SafeTryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
|
||||
if (!string.IsNullOrWhiteSpace(heading) || !string.IsNullOrWhiteSpace(content))
|
||||
sections.Add((heading, content, level));
|
||||
}
|
||||
|
||||
@@ -84,12 +84,12 @@ public class DocumentPlannerTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var topic = args.GetProperty("topic").GetString() ?? "";
|
||||
var docType = args.TryGetProperty("document_type", out var dt) ? dt.GetString() ?? "report" : "report";
|
||||
var targetPages = args.TryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
|
||||
var requestedFormat = args.TryGetProperty("format", out var fmt) ? fmt.GetString() ?? "auto" : GetDefaultOutputFormat();
|
||||
var sectionsHint = args.TryGetProperty("sections_hint", out var sh) ? sh.GetString() ?? "" : "";
|
||||
var refSummary = args.TryGetProperty("reference_summary", out var rs) ? rs.GetString() ?? "" : "";
|
||||
var topic = args.GetProperty("topic").SafeGetString() ?? "";
|
||||
var docType = args.SafeTryGetProperty("document_type", out var dt) ? dt.SafeGetString() ?? "report" : "report";
|
||||
var targetPages = args.SafeTryGetProperty("target_pages", out var tp) ? tp.GetInt32() : 5;
|
||||
var requestedFormat = args.SafeTryGetProperty("format", out var fmt) ? fmt.SafeGetString() ?? "auto" : GetDefaultOutputFormat();
|
||||
var sectionsHint = args.SafeTryGetProperty("sections_hint", out var sh) ? sh.SafeGetString() ?? "" : "";
|
||||
var refSummary = args.SafeTryGetProperty("reference_summary", out var rs) ? rs.SafeGetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(topic))
|
||||
return Task.FromResult(ToolResult.Fail("topic이 비어있습니다."));
|
||||
|
||||
@@ -49,14 +49,14 @@ public class DocumentReaderTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("path", out var pathEl))
|
||||
if (!args.SafeTryGetProperty("path", out var pathEl))
|
||||
return ToolResult.Fail("path가 필요합니다.");
|
||||
var path = pathEl.GetString() ?? "";
|
||||
var maxChars = args.TryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars;
|
||||
var offset = args.TryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0;
|
||||
var sheetParam = args.TryGetProperty("sheet", out var sh) ? sh.GetString() ?? "" : "";
|
||||
var pagesParam = args.TryGetProperty("pages", out var pg) ? pg.GetString() ?? "" : "";
|
||||
var sectionParam = args.TryGetProperty("section", out var sec) ? sec.GetString() ?? "" : "";
|
||||
var path = pathEl.SafeGetString() ?? "";
|
||||
var maxChars = args.SafeTryGetProperty("max_chars", out var mc) ? GetIntValue(mc, DefaultMaxChars) : DefaultMaxChars;
|
||||
var offset = args.SafeTryGetProperty("offset", out var off) ? GetIntValue(off, 0) : 0;
|
||||
var sheetParam = args.SafeTryGetProperty("sheet", out var sh) ? sh.SafeGetString() ?? "" : "";
|
||||
var pagesParam = args.SafeTryGetProperty("pages", out var pg) ? pg.SafeGetString() ?? "" : "";
|
||||
var sectionParam = args.SafeTryGetProperty("section", out var sec) ? sec.SafeGetString() ?? "" : "";
|
||||
|
||||
if (maxChars < 100) maxChars = DefaultMaxChars;
|
||||
if (offset < 0) offset = 0;
|
||||
@@ -565,7 +565,7 @@ public class DocumentReaderTool : IAgentTool
|
||||
private static int GetIntValue(JsonElement el, int defaultValue)
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Number) return el.GetInt32();
|
||||
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.GetString(), out var v)) return v;
|
||||
if (el.ValueKind == JsonValueKind.String && int.TryParse(el.SafeGetString(), out var v)) return v;
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ public class DocumentReviewTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("path", out var pathEl))
|
||||
if (!args.SafeTryGetProperty("path", out var pathEl))
|
||||
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
|
||||
var path = pathEl.GetString() ?? "";
|
||||
var path = pathEl.SafeGetString() ?? "";
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
@@ -108,11 +108,11 @@ public class DocumentReviewTool : IAgentTool
|
||||
}
|
||||
|
||||
// 5. 기대 섹션 검사
|
||||
if (args.TryGetProperty("expected_sections", out var sections) && sections.ValueKind == JsonValueKind.Array)
|
||||
if (args.SafeTryGetProperty("expected_sections", out var sections) && sections.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var sec in sections.EnumerateArray())
|
||||
{
|
||||
var title = sec.GetString() ?? "";
|
||||
var title = sec.SafeGetString() ?? "";
|
||||
if (!string.IsNullOrEmpty(title) && !content.Contains(title, StringComparison.OrdinalIgnoreCase))
|
||||
issues.Add($"[MISSING] 기대 섹션 누락: \"{title}\"");
|
||||
}
|
||||
|
||||
@@ -94,12 +94,12 @@ public class DocxSkill : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
|
||||
var title = args.SafeTryGetProperty("title", out var t) ? t.SafeGetString() ?? "" : "";
|
||||
string path;
|
||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
||||
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||
{
|
||||
path = pathEl.GetString()!;
|
||||
path = pathEl.SafeGetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -108,12 +108,12 @@ public class DocxSkill : IAgentTool
|
||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".docx";
|
||||
}
|
||||
var headerText = args.TryGetProperty("header", out var hdr) ? hdr.GetString() : null;
|
||||
var footerText = args.TryGetProperty("footer", out var ftr) ? ftr.GetString() : null;
|
||||
var showPageNumbers = args.TryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
|
||||
var headerText = args.SafeTryGetProperty("header", out var hdr) ? hdr.SafeGetString() : null;
|
||||
var footerText = args.SafeTryGetProperty("footer", out var ftr) ? ftr.SafeGetString() : null;
|
||||
var showPageNumbers = args.SafeTryGetProperty("page_numbers", out var pn) ? pn.GetBoolean() :
|
||||
(headerText != null || footerText != null);
|
||||
|
||||
var themeName = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
|
||||
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "professional" : "professional";
|
||||
var theme = Themes.TryGetValue(themeName, out var tc) ? tc : Themes["professional"];
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
@@ -161,7 +161,7 @@ public class DocxSkill : IAgentTool
|
||||
int tableCount = 0;
|
||||
foreach (var section in sections.EnumerateArray())
|
||||
{
|
||||
var blockType = section.TryGetProperty("type", out var bt) ? bt.GetString()?.ToLower() : null;
|
||||
var blockType = section.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString()?.ToLower() : null;
|
||||
|
||||
if (blockType == "pagebreak")
|
||||
{
|
||||
@@ -195,9 +195,9 @@ public class DocxSkill : IAgentTool
|
||||
}
|
||||
|
||||
// 일반 섹션 (heading + body)
|
||||
var heading = section.TryGetProperty("heading", out var h) ? h.GetString() ?? "" : "";
|
||||
var bodyText = section.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
|
||||
var level = section.TryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
|
||||
var heading = section.SafeTryGetProperty("heading", out var h) ? h.SafeGetString() ?? "" : "";
|
||||
var bodyText = section.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : "";
|
||||
var level = section.SafeTryGetProperty("level", out var lv) ? lv.GetInt32() : 1;
|
||||
|
||||
if (!string.IsNullOrEmpty(heading))
|
||||
body.Append(CreateHeadingParagraph(heading, level, theme));
|
||||
@@ -371,9 +371,9 @@ public class DocxSkill : IAgentTool
|
||||
|
||||
private static Table CreateTable(JsonElement section, ThemeColors theme)
|
||||
{
|
||||
var headers = section.TryGetProperty("headers", out var hArr) ? hArr : default;
|
||||
var rows = section.TryGetProperty("rows", out var rArr) ? rArr : default;
|
||||
var tableStyle = section.TryGetProperty("style", out var ts) ? ts.GetString() ?? "striped" : "striped";
|
||||
var headers = section.SafeTryGetProperty("headers", out var hArr) ? hArr : default;
|
||||
var rows = section.SafeTryGetProperty("rows", out var rArr) ? rArr : default;
|
||||
var tableStyle = section.SafeTryGetProperty("style", out var ts) ? ts.SafeGetString() ?? "striped" : "striped";
|
||||
|
||||
var table = new Table();
|
||||
|
||||
@@ -403,7 +403,7 @@ public class DocxSkill : IAgentTool
|
||||
Shading = new Shading { Val = ShadingPatternValues.Clear, Fill = theme.TableHeader, Color = "auto" },
|
||||
TableCellVerticalAlignment = new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center },
|
||||
};
|
||||
var para = new Paragraph(new Run(new Text(h.GetString() ?? ""))
|
||||
var para = new Paragraph(new Run(new Text(h.SafeGetString() ?? ""))
|
||||
{
|
||||
RunProperties = new RunProperties
|
||||
{
|
||||
@@ -472,15 +472,15 @@ public class DocxSkill : IAgentTool
|
||||
|
||||
private static void AppendList(Body body, JsonElement section)
|
||||
{
|
||||
var items = section.TryGetProperty("items", out var arr) ? arr : default;
|
||||
var listStyle = section.TryGetProperty("style", out var ls) ? ls.GetString() ?? "bullet" : "bullet";
|
||||
var items = section.SafeTryGetProperty("items", out var arr) ? arr : default;
|
||||
var listStyle = section.SafeTryGetProperty("style", out var ls) ? ls.SafeGetString() ?? "bullet" : "bullet";
|
||||
|
||||
if (items.ValueKind != JsonValueKind.Array) return;
|
||||
|
||||
int idx = 1;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var rawText = item.GetString() ?? item.ToString();
|
||||
var rawText = item.SafeGetString() ?? item.ToString();
|
||||
|
||||
// 서브불릿 감지: 앞에 공백/탭 또는 "- " 시작
|
||||
bool isSub = rawText.StartsWith(" ") || rawText.StartsWith("\t") || rawText.TrimStart().StartsWith("- ");
|
||||
@@ -526,9 +526,9 @@ public class DocxSkill : IAgentTool
|
||||
|
||||
private static void AppendCallout(Body body, JsonElement section)
|
||||
{
|
||||
var style = section.TryGetProperty("style", out var sv) ? sv.GetString() ?? "info" : "info";
|
||||
var calloutTitle = section.TryGetProperty("title", out var tv) ? tv.GetString() ?? "" : "";
|
||||
var calloutBody = section.TryGetProperty("body", out var bv) ? bv.GetString() ?? "" : "";
|
||||
var style = section.SafeTryGetProperty("style", out var sv) ? sv.SafeGetString() ?? "info" : "info";
|
||||
var calloutTitle = section.SafeTryGetProperty("title", out var tv) ? tv.SafeGetString() ?? "" : "";
|
||||
var calloutBody = section.SafeTryGetProperty("body", out var bv) ? bv.SafeGetString() ?? "" : "";
|
||||
|
||||
var (borderColor, fillColor) = CalloutColors.TryGetValue(style, out var cc)
|
||||
? cc
|
||||
@@ -588,8 +588,8 @@ public class DocxSkill : IAgentTool
|
||||
|
||||
private static Paragraph CreateHighlightBox(JsonElement section)
|
||||
{
|
||||
var text = section.TryGetProperty("text", out var tv) ? tv.GetString() ?? "" : "";
|
||||
var color = section.TryGetProperty("color", out var cv) ? cv.GetString() ?? "blue" : "blue";
|
||||
var text = section.SafeTryGetProperty("text", out var tv) ? tv.SafeGetString() ?? "" : "";
|
||||
var color = section.SafeTryGetProperty("color", out var cv) ? cv.SafeGetString() ?? "blue" : "blue";
|
||||
|
||||
var (fillColor, borderColor) = HighlightBoxColors.TryGetValue(color, out var hc)
|
||||
? hc
|
||||
|
||||
199
src/AxCopilot/Services/Agent/DocxToHtmlConverter.cs
Normal file
199
src/AxCopilot/Services/Agent/DocxToHtmlConverter.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Wordprocessing;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>DOCX 파일을 HTML로 변환하여 WebView2에서 미리보기합니다.</summary>
|
||||
internal static class DocxToHtmlConverter
|
||||
{
|
||||
public static string Convert(string docxPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = WordprocessingDocument.Open(docxPath, false);
|
||||
var body = doc.MainDocumentPart?.Document?.Body;
|
||||
if (body == null)
|
||||
return WrapHtml("<p style='color:#888'>문서 내용이 없습니다.</p>");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
foreach (var element in body.Elements())
|
||||
{
|
||||
if (element is Paragraph para)
|
||||
sb.AppendLine(RenderParagraph(para, doc));
|
||||
else if (element is Table table)
|
||||
sb.AppendLine(RenderTable(table, doc));
|
||||
}
|
||||
|
||||
return WrapHtml(sb.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return WrapHtml($"<p style='color:#e53e3e'>DOCX 파싱 오류: {Escape(ex.Message)}</p>");
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderParagraph(Paragraph para, WordprocessingDocument doc)
|
||||
{
|
||||
var style = para.ParagraphProperties?.ParagraphStyleId?.Val?.Value;
|
||||
var text = RenderRuns(para, doc);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return "<p> </p>";
|
||||
|
||||
// 제목 스타일 감지
|
||||
if (!string.IsNullOrEmpty(style))
|
||||
{
|
||||
var lower = style.ToLowerInvariant();
|
||||
if (lower.Contains("heading1") || lower == "1") return $"<h1>{text}</h1>";
|
||||
if (lower.Contains("heading2") || lower == "2") return $"<h2>{text}</h2>";
|
||||
if (lower.Contains("heading3") || lower == "3") return $"<h3>{text}</h3>";
|
||||
if (lower.Contains("heading4") || lower == "4") return $"<h4>{text}</h4>";
|
||||
if (lower.Contains("heading5") || lower == "5") return $"<h5>{text}</h5>";
|
||||
if (lower.Contains("heading6") || lower == "6") return $"<h6>{text}</h6>";
|
||||
if (lower.Contains("title")) return $"<h1 class='title'>{text}</h1>";
|
||||
if (lower.Contains("subtitle")) return $"<p class='subtitle'>{text}</p>";
|
||||
if (lower.Contains("listparagraph") || lower.Contains("list"))
|
||||
return $"<li>{text}</li>";
|
||||
}
|
||||
|
||||
// 글머리 기호 감지
|
||||
var numProps = para.ParagraphProperties?.NumberingProperties;
|
||||
if (numProps != null)
|
||||
return $"<li>{text}</li>";
|
||||
|
||||
// 정렬
|
||||
var align = para.ParagraphProperties?.Justification?.Val?.Value;
|
||||
var alignStyle = "";
|
||||
if (align == JustificationValues.Center) alignStyle = " style='text-align:center'";
|
||||
else if (align == JustificationValues.Right) alignStyle = " style='text-align:right'";
|
||||
|
||||
return $"<p{alignStyle}>{text}</p>";
|
||||
}
|
||||
|
||||
private static string RenderRuns(Paragraph para, WordprocessingDocument doc)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var run in para.Elements<Run>())
|
||||
{
|
||||
var text = string.Join("", run.Elements<Text>().Select(t => Escape(t.Text)));
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
// 이미지 체크
|
||||
var drawing = run.Elements<Drawing>().FirstOrDefault();
|
||||
if (drawing != null)
|
||||
{
|
||||
var img = ExtractImage(drawing, doc);
|
||||
if (img != null)
|
||||
sb.Append(img);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var rp = run.RunProperties;
|
||||
if (rp?.Bold is { } bold && (bold.Val is null || bold.Val.Value))
|
||||
text = $"<strong>{text}</strong>";
|
||||
if (rp?.Italic is { } italic && (italic.Val is null || italic.Val.Value))
|
||||
text = $"<em>{text}</em>";
|
||||
if (rp?.Underline?.Val is { } underlineVal && underlineVal != UnderlineValues.None)
|
||||
text = $"<u>{text}</u>";
|
||||
if (rp?.Strike is { } strike && (strike.Val is null || strike.Val.Value))
|
||||
text = $"<del>{text}</del>";
|
||||
|
||||
// 글자 색상
|
||||
var color = rp?.Color?.Val?.Value;
|
||||
if (!string.IsNullOrEmpty(color) && color != "000000" && color != "auto")
|
||||
text = $"<span style='color:#{color}'>{text}</span>";
|
||||
|
||||
sb.Append(text);
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? ExtractImage(Drawing drawing, WordprocessingDocument doc)
|
||||
{
|
||||
try
|
||||
{
|
||||
var blip = drawing.Descendants<DocumentFormat.OpenXml.Drawing.Blip>().FirstOrDefault();
|
||||
if (blip?.Embed?.Value == null) return null;
|
||||
|
||||
var part = doc.MainDocumentPart?.GetPartById(blip.Embed.Value);
|
||||
if (part == null) return null;
|
||||
|
||||
using var stream = part.GetStream();
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
var base64 = System.Convert.ToBase64String(ms.ToArray());
|
||||
var mime = part.ContentType ?? "image/png";
|
||||
return $"<img src='data:{mime};base64,{base64}' style='max-width:100%;height:auto;margin:8px 0' />";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderTable(Table table, WordprocessingDocument doc)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<table>");
|
||||
foreach (var row in table.Elements<TableRow>())
|
||||
{
|
||||
sb.Append("<tr>");
|
||||
foreach (var cell in row.Elements<TableCell>())
|
||||
{
|
||||
var cellContent = new StringBuilder();
|
||||
foreach (var para in cell.Elements<Paragraph>())
|
||||
cellContent.Append(RenderRuns(para, doc));
|
||||
|
||||
// 셀 병합
|
||||
var gridSpan = cell.TableCellProperties?.GridSpan?.Val?.Value;
|
||||
var spanAttr = gridSpan > 1 ? $" colspan='{gridSpan}'" : "";
|
||||
|
||||
sb.Append($"<td{spanAttr}>{cellContent}</td>");
|
||||
}
|
||||
sb.AppendLine("</tr>");
|
||||
}
|
||||
sb.AppendLine("</table>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Escape(string text)
|
||||
=> System.Net.WebUtility.HtmlEncode(text);
|
||||
|
||||
private static string WrapHtml(string body)
|
||||
{
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang='ko'>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
|
||||
line-height: 1.7; padding: 32px 28px; font-size: 13.5px; }}
|
||||
h1 {{ font-size: 22px; font-weight: 700; margin: 24px 0 10px; color: #111; }}
|
||||
h1.title {{ font-size: 26px; text-align: center; margin-bottom: 4px; }}
|
||||
.subtitle {{ font-size: 14px; color: #666; text-align: center; margin-bottom: 20px; }}
|
||||
h2 {{ font-size: 18px; font-weight: 600; margin: 20px 0 8px; color: #222;
|
||||
border-bottom: 2px solid #e5e7eb; padding-bottom: 4px; }}
|
||||
h3 {{ font-size: 15px; font-weight: 600; margin: 16px 0 6px; color: #333; }}
|
||||
h4,h5,h6 {{ font-size: 13.5px; font-weight: 600; margin: 12px 0 4px; }}
|
||||
p {{ margin: 6px 0; }}
|
||||
li {{ margin: 3px 0 3px 24px; }}
|
||||
table {{ width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 12.5px; }}
|
||||
th, td {{ border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }}
|
||||
th {{ background: #f3f4f6; font-weight: 600; }}
|
||||
tr:nth-child(even) {{ background: #f9fafb; }}
|
||||
img {{ border-radius: 4px; }}
|
||||
strong {{ font-weight: 600; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{body}
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,12 @@ public class EncodingTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
|
||||
if (action == "list")
|
||||
return ListEncodings();
|
||||
|
||||
var rawPath = args.TryGetProperty("path", out var pv) ? pv.GetString() ?? "" : "";
|
||||
var rawPath = args.SafeTryGetProperty("path", out var pv) ? pv.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(rawPath))
|
||||
return ToolResult.Fail("'path'가 필요합니다.");
|
||||
|
||||
@@ -90,7 +90,7 @@ public class EncodingTool : IAgentTool
|
||||
|
||||
private static async Task<ToolResult> ConvertEncoding(string path, JsonElement args, AgentContext context)
|
||||
{
|
||||
var toName = args.TryGetProperty("to_encoding", out var te) ? te.GetString() ?? "utf-8" : "utf-8";
|
||||
var toName = args.SafeTryGetProperty("to_encoding", out var te) ? te.SafeGetString() ?? "utf-8" : "utf-8";
|
||||
|
||||
// 쓰기 권한 확인
|
||||
var allowed = await context.CheckWritePermissionAsync("encoding_tool", path);
|
||||
@@ -98,10 +98,10 @@ public class EncodingTool : IAgentTool
|
||||
|
||||
// 소스 인코딩 결정
|
||||
Encoding fromEnc;
|
||||
if (args.TryGetProperty("from_encoding", out var fe) && !string.IsNullOrEmpty(fe.GetString()))
|
||||
if (args.SafeTryGetProperty("from_encoding", out var fe) && !string.IsNullOrEmpty(fe.SafeGetString()))
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
fromEnc = Encoding.GetEncoding(fe.GetString()!);
|
||||
fromEnc = Encoding.GetEncoding(fe.SafeGetString()!);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -20,13 +20,13 @@ public sealed class EnterWorktreeTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var relative = args.TryGetProperty("path", out var pathEl) ? (pathEl.GetString() ?? "").Trim() : "";
|
||||
var relative = args.SafeTryGetProperty("path", out var pathEl) ? (pathEl.SafeGetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(relative))
|
||||
return Task.FromResult(ToolResult.Fail("path is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var create = !args.TryGetProperty("create", out var createEl) || createEl.ValueKind != JsonValueKind.False;
|
||||
var create = !args.SafeTryGetProperty("create", out var createEl) || createEl.ValueKind != JsonValueKind.False;
|
||||
var root = WorktreeStateStore.ResolveRoot(context.WorkFolder);
|
||||
var full = Path.GetFullPath(Path.Combine(root, relative));
|
||||
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -47,7 +47,7 @@ public class EnvTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -68,9 +68,9 @@ public class EnvTool : IAgentTool
|
||||
|
||||
private static ToolResult Get(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("name", out var n))
|
||||
if (!args.SafeTryGetProperty("name", out var n))
|
||||
return ToolResult.Fail("'name' parameter is required for get action");
|
||||
var name = n.GetString() ?? "";
|
||||
var name = n.SafeGetString() ?? "";
|
||||
var value = Environment.GetEnvironmentVariable(name);
|
||||
return value != null
|
||||
? ToolResult.Ok($"{name}={value}")
|
||||
@@ -79,20 +79,20 @@ public class EnvTool : IAgentTool
|
||||
|
||||
private static ToolResult Set(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("name", out var n))
|
||||
if (!args.SafeTryGetProperty("name", out var n))
|
||||
return ToolResult.Fail("'name' parameter is required for set action");
|
||||
if (!args.TryGetProperty("value", out var v))
|
||||
if (!args.SafeTryGetProperty("value", out var v))
|
||||
return ToolResult.Fail("'value' parameter is required for set action");
|
||||
|
||||
var name = n.GetString() ?? "";
|
||||
var value = v.GetString() ?? "";
|
||||
var name = n.SafeGetString() ?? "";
|
||||
var value = v.SafeGetString() ?? "";
|
||||
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
|
||||
return ToolResult.Ok($"✓ Set {name}={value} (process scope)");
|
||||
}
|
||||
|
||||
private static ToolResult ListVars(JsonElement args)
|
||||
{
|
||||
var filter = args.TryGetProperty("filter", out var f) ? f.GetString() ?? "" : "";
|
||||
var filter = args.SafeTryGetProperty("filter", out var f) ? f.SafeGetString() ?? "" : "";
|
||||
var vars = Environment.GetEnvironmentVariables();
|
||||
var entries = new List<string>();
|
||||
|
||||
@@ -118,9 +118,9 @@ public class EnvTool : IAgentTool
|
||||
|
||||
private static ToolResult Expand(JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("value", out var v))
|
||||
if (!args.SafeTryGetProperty("value", out var v))
|
||||
return ToolResult.Fail("'value' parameter is required for expand action");
|
||||
var input = v.GetString() ?? "";
|
||||
var input = v.SafeGetString() ?? "";
|
||||
var expanded = Environment.ExpandEnvironmentVariables(input);
|
||||
return ToolResult.Ok(expanded);
|
||||
}
|
||||
|
||||
@@ -84,15 +84,15 @@ public class ExcelSkill : IAgentTool
|
||||
{
|
||||
// path 미제공 시 title 또는 sheet_name에서 자동 생성
|
||||
string path;
|
||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
||||
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||
{
|
||||
path = pathEl.GetString()!;
|
||||
path = pathEl.SafeGetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
var hint = (args.TryGetProperty("title", out var tEl) ? tEl.GetString() : null)
|
||||
?? (args.TryGetProperty("sheet_name", out var snEl) ? snEl.GetString() : null)
|
||||
var hint = (args.SafeTryGetProperty("title", out var tEl) ? tEl.SafeGetString() : null)
|
||||
?? (args.SafeTryGetProperty("sheet_name", out var snEl) ? snEl.SafeGetString() : null)
|
||||
?? "workbook";
|
||||
var safe = System.Text.RegularExpressions.Regex.Replace(hint, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||
@@ -116,7 +116,7 @@ public class ExcelSkill : IAgentTool
|
||||
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||
|
||||
// Determine if we are in multi-sheet mode
|
||||
var multiSheetMode = args.TryGetProperty("sheets", out var sheetsArr)
|
||||
var multiSheetMode = args.SafeTryGetProperty("sheets", out var sheetsArr)
|
||||
&& sheetsArr.ValueKind == JsonValueKind.Array
|
||||
&& sheetsArr.GetArrayLength() > 0;
|
||||
|
||||
@@ -137,16 +137,16 @@ public class ExcelSkill : IAgentTool
|
||||
|
||||
private static ToolResult GenerateSingleSheetWorkbook(JsonElement args, string fullPath)
|
||||
{
|
||||
if (!args.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("필수 파라미터 누락: 'headers'");
|
||||
if (!args.TryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("필수 파라미터 누락: 'rows'");
|
||||
|
||||
var sheetName = args.TryGetProperty("sheet_name", out var sn) ? sn.GetString() ?? "Sheet1" : "Sheet1";
|
||||
var tableStyle = args.TryGetProperty("style", out var st) ? st.GetString() ?? "styled" : "styled";
|
||||
var themeName = args.TryGetProperty("theme", out var th) ? th.GetString() : null;
|
||||
var sheetName = args.SafeTryGetProperty("sheet_name", out var sn) ? sn.SafeGetString() ?? "Sheet1" : "Sheet1";
|
||||
var tableStyle = args.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "styled" : "styled";
|
||||
var themeName = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() : null;
|
||||
var isStyled = tableStyle != "plain";
|
||||
var freezeHeader= args.TryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
|
||||
var freezeHeader= args.SafeTryGetProperty("freeze_header", out var fh) ? fh.GetBoolean() : isStyled;
|
||||
|
||||
var theme = GetTheme(themeName);
|
||||
var numFmts = ParseNumberFormats(args, "number_formats");
|
||||
@@ -168,8 +168,8 @@ public class ExcelSkill : IAgentTool
|
||||
|
||||
var colCount = headers.GetArrayLength();
|
||||
|
||||
var summaryArg = args.TryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||
var mergesArg = args.TryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||
var summaryArg = args.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||
var mergesArg = args.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||
|
||||
var rowCount = WriteSheetContent(worksheetPart, args, sheetName, headers, rows,
|
||||
isStyled, freezeHeader, theme, numFmts, alignments, customFmts,
|
||||
@@ -218,7 +218,7 @@ public class ExcelSkill : IAgentTool
|
||||
}
|
||||
|
||||
// Use first sheet's theme for the shared stylesheet
|
||||
var firstThemeName = sheetsArr[0].TryGetProperty("theme", out var ft) ? ft.GetString() : null;
|
||||
var firstThemeName = sheetsArr[0].SafeTryGetProperty("theme", out var ft) ? ft.SafeGetString() : null;
|
||||
var firstTheme = GetTheme(firstThemeName);
|
||||
|
||||
var combinedCustomFmts = CollectCustomFormats(allNumFmts);
|
||||
@@ -235,21 +235,21 @@ public class ExcelSkill : IAgentTool
|
||||
|
||||
foreach (var sheetDef in sheetsArr.EnumerateArray())
|
||||
{
|
||||
var sheetName = sheetDef.TryGetProperty("name", out var snEl) ? snEl.GetString() ?? $"Sheet{sheetId}" : $"Sheet{sheetId}";
|
||||
var tableStyle = sheetDef.TryGetProperty("style", out var stEl) ? stEl.GetString() ?? "styled" : "styled";
|
||||
var themeName = sheetDef.TryGetProperty("theme", out var thEl) ? thEl.GetString() : firstThemeName;
|
||||
var sheetName = sheetDef.SafeTryGetProperty("name", out var snEl) ? snEl.SafeGetString() ?? $"Sheet{sheetId}" : $"Sheet{sheetId}";
|
||||
var tableStyle = sheetDef.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString() ?? "styled" : "styled";
|
||||
var themeName = sheetDef.SafeTryGetProperty("theme", out var thEl) ? thEl.SafeGetString() : firstThemeName;
|
||||
var isStyled = tableStyle != "plain";
|
||||
var freezeHeader = sheetDef.TryGetProperty("freeze_header", out var fhEl) ? fhEl.GetBoolean() : isStyled;
|
||||
var freezeHeader = sheetDef.SafeTryGetProperty("freeze_header", out var fhEl) ? fhEl.GetBoolean() : isStyled;
|
||||
var theme = GetTheme(themeName);
|
||||
var numFmts = ParseNumberFormats(sheetDef, "number_formats");
|
||||
var alignments = ParseAlignments(sheetDef, "col_alignments");
|
||||
|
||||
if (!sheetDef.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) continue;
|
||||
if (!sheetDef.TryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array) continue;
|
||||
if (!sheetDef.SafeTryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array) continue;
|
||||
if (!sheetDef.SafeTryGetProperty("rows", out var rows) || rows.ValueKind != JsonValueKind.Array) continue;
|
||||
|
||||
var colCount = headers.GetArrayLength();
|
||||
var summaryArg = sheetDef.TryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||
var mergesArg = sheetDef.TryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||
var summaryArg = sheetDef.SafeTryGetProperty("summary_row", out var sumEl) ? sumEl : default;
|
||||
var mergesArg = sheetDef.SafeTryGetProperty("merges", out var mergeEl) ? mergeEl : default;
|
||||
|
||||
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>();
|
||||
worksheetPart.Worksheet = new Worksheet();
|
||||
@@ -315,7 +315,7 @@ public class ExcelSkill : IAgentTool
|
||||
{
|
||||
CellReference = cellRef,
|
||||
DataType = CellValues.String,
|
||||
CellValue = new CellValue(h.ValueKind != JsonValueKind.Undefined ? h.GetString() ?? "" : ""),
|
||||
CellValue = new CellValue(h.ValueKind != JsonValueKind.Undefined ? h.SafeGetString() ?? "" : ""),
|
||||
StyleIndex = isStyled ? (uint)1 : 0,
|
||||
};
|
||||
headerRow.Append(cell);
|
||||
@@ -374,7 +374,7 @@ public class ExcelSkill : IAgentTool
|
||||
var mergeCells = new MergeCells();
|
||||
foreach (var merge in mergesArg.EnumerateArray())
|
||||
{
|
||||
var range = merge.GetString();
|
||||
var range = merge.SafeGetString();
|
||||
if (!string.IsNullOrEmpty(range))
|
||||
mergeCells.Append(new MergeCell { Reference = range });
|
||||
}
|
||||
@@ -678,7 +678,7 @@ public class ExcelSkill : IAgentTool
|
||||
|
||||
private static Columns? CreateColumns(JsonElement args, int colCount)
|
||||
{
|
||||
var hasWidths = args.TryGetProperty("col_widths", out var widthsArr) && widthsArr.ValueKind == JsonValueKind.Array;
|
||||
var hasWidths = args.SafeTryGetProperty("col_widths", out var widthsArr) && widthsArr.ValueKind == JsonValueKind.Array;
|
||||
|
||||
var columns = new Columns();
|
||||
for (int i = 0; i < colCount; i++)
|
||||
@@ -706,8 +706,8 @@ public class ExcelSkill : IAgentTool
|
||||
private static void AddSummaryRow(SheetData sheetData, JsonElement summary,
|
||||
uint rowNum, int colCount, int dataRowCount, bool isStyled)
|
||||
{
|
||||
var label = summary.TryGetProperty("label", out var lbl) ? lbl.GetString() ?? "합계" : "합계";
|
||||
var colFormulas = summary.TryGetProperty("columns", out var cols) ? cols : default;
|
||||
var label = summary.SafeTryGetProperty("label", out var lbl) ? lbl.SafeGetString() ?? "합계" : "합계";
|
||||
var colFormulas = summary.SafeTryGetProperty("columns", out var cols) ? cols : default;
|
||||
|
||||
var summaryRow = new Row { RowIndex = rowNum };
|
||||
|
||||
@@ -730,9 +730,9 @@ public class ExcelSkill : IAgentTool
|
||||
};
|
||||
|
||||
if (colFormulas.ValueKind == JsonValueKind.Object &&
|
||||
colFormulas.TryGetProperty(colLetter, out var funcName))
|
||||
colFormulas.SafeTryGetProperty(colLetter, out var funcName))
|
||||
{
|
||||
var func = funcName.GetString()?.ToUpper() ?? "SUM";
|
||||
var func = funcName.SafeGetString()?.ToUpper() ?? "SUM";
|
||||
var startRow = 2;
|
||||
var endRow = startRow + dataRowCount - 1;
|
||||
cell.CellFormula = new CellFormula($"={func}({colLetter}{startRow}:{colLetter}{endRow})");
|
||||
@@ -751,20 +751,20 @@ public class ExcelSkill : IAgentTool
|
||||
private static List<string?> ParseNumberFormats(JsonElement args, string key)
|
||||
{
|
||||
var result = new List<string?>();
|
||||
if (!args.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return result;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.GetString());
|
||||
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.SafeGetString());
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<string?> ParseAlignments(JsonElement args, string key)
|
||||
{
|
||||
var result = new List<string?>();
|
||||
if (!args.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array)
|
||||
return result;
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.GetString()?.ToLower());
|
||||
result.Add(el.ValueKind == JsonValueKind.Null ? null : el.SafeGetString()?.ToLower());
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ public class FileEditTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var oldStr = args.GetProperty("old_string").GetString() ?? "";
|
||||
var newStr = args.GetProperty("new_string").GetString() ?? "";
|
||||
var replaceAll = args.TryGetProperty("replace_all", out var ra) && ra.GetBoolean();
|
||||
var path = args.GetProperty("path").SafeGetString() ?? "";
|
||||
var oldStr = args.GetProperty("old_string").SafeGetString() ?? "";
|
||||
var newStr = args.GetProperty("new_string").SafeGetString() ?? "";
|
||||
var replaceAll = args.SafeTryGetProperty("replace_all", out var ra) && ra.GetBoolean();
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
|
||||
@@ -47,7 +47,11 @@ public class FileEditTool : IAgentTool
|
||||
|
||||
var count = CountOccurrences(content, oldStr);
|
||||
if (count == 0)
|
||||
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.");
|
||||
{
|
||||
// LLM이 수정할 수 있도록 파일 내용 일부를 함께 반환
|
||||
var hint = BuildNotFoundHint(content, oldStr);
|
||||
return ToolResult.Fail($"old_string을 파일에서 찾을 수 없습니다.{hint}");
|
||||
}
|
||||
if (!replaceAll && count > 1)
|
||||
return ToolResult.Fail($"old_string이 {count}번 발견됩니다. replace_all=true로 전체 교체하거나, 고유한 문자열을 지정하세요.");
|
||||
|
||||
@@ -113,6 +117,43 @@ public class FileEditTool : IAgentTool
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>old_string 미발견 시 LLM에 파일 내용 힌트를 제공합니다.</summary>
|
||||
private static string BuildNotFoundHint(string content, string oldStr)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content)) return " 파일이 비어있습니다.";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// 유사 행 검색: old_string의 첫 줄로 근사 매치 시도
|
||||
var firstLine = oldStr.Split('\n')[0].Trim().TrimEnd('\r');
|
||||
if (firstLine.Length >= 8)
|
||||
{
|
||||
var lines = content.Split('\n');
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (lines[i].Contains(firstLine, StringComparison.OrdinalIgnoreCase)
|
||||
|| (firstLine.Length >= 15 && lines[i].Trim().TrimEnd('\r').StartsWith(firstLine[..15], StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var start = Math.Max(0, i - 1);
|
||||
var end = Math.Min(lines.Length - 1, i + 3);
|
||||
sb.AppendLine($"\n\n유사한 내용 발견 (줄 {i + 1}):");
|
||||
for (int j = start; j <= end; j++)
|
||||
sb.AppendLine($" {j + 1}: {lines[j].TrimEnd('\r')}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 파일이 짧으면 전체 내용 표시
|
||||
if (sb.Length == 0)
|
||||
{
|
||||
var preview = content.Length > 2000 ? content[..2000] + "\n...(truncated)" : content;
|
||||
sb.AppendLine($"\n\n현재 파일 내용:\n{preview}");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string text, string search)
|
||||
{
|
||||
if (string.IsNullOrEmpty(search)) return 0;
|
||||
|
||||
@@ -27,7 +27,7 @@ public class FileInfoTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
||||
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
|
||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||
|
||||
if (!context.IsPathAllowed(path))
|
||||
|
||||
@@ -41,9 +41,9 @@ public class FileManageTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
||||
var dest = args.TryGetProperty("destination", out var d) ? d.GetString() ?? "" : "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
|
||||
var dest = args.SafeTryGetProperty("destination", out var d) ? d.SafeGetString() ?? "" : "";
|
||||
|
||||
var path = Path.IsPathRooted(rawPath) ? rawPath : Path.Combine(context.WorkFolder, rawPath);
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ public class FileReadTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("path", out var pathEl))
|
||||
if (!args.SafeTryGetProperty("path", out var pathEl))
|
||||
return Task.FromResult(ToolResult.Fail("path가 필요합니다."));
|
||||
var path = pathEl.GetString() ?? "";
|
||||
var offset = args.TryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
|
||||
var limit = args.TryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
|
||||
var path = pathEl.SafeGetString() ?? "";
|
||||
var offset = args.SafeTryGetProperty("offset", out var ofs) ? ofs.GetInt32() : 1;
|
||||
var limit = args.SafeTryGetProperty("limit", out var lim) ? lim.GetInt32() : 500;
|
||||
|
||||
var fullPath = ResolvePath(path, context.WorkFolder);
|
||||
|
||||
|
||||
@@ -57,12 +57,12 @@ public class FileWatchTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var pattern = args.TryGetProperty("pattern", out var patEl) ? patEl.GetString() ?? "*" : "*";
|
||||
var sinceStr = args.TryGetProperty("since", out var sinceEl) ? sinceEl.GetString() ?? "24h" : "24h";
|
||||
var recursive = !args.TryGetProperty("recursive", out var recEl) || recEl.GetBoolean(); // default true
|
||||
var includeSize = !args.TryGetProperty("include_size", out var sizeEl) || sizeEl.GetBoolean();
|
||||
var topN = args.TryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var n) ? n : 50;
|
||||
var path = args.GetProperty("path").SafeGetString() ?? "";
|
||||
var pattern = args.SafeTryGetProperty("pattern", out var patEl) ? patEl.SafeGetString() ?? "*" : "*";
|
||||
var sinceStr = args.SafeTryGetProperty("since", out var sinceEl) ? sinceEl.SafeGetString() ?? "24h" : "24h";
|
||||
var recursive = !args.SafeTryGetProperty("recursive", out var recEl) || recEl.GetBoolean(); // default true
|
||||
var includeSize = !args.SafeTryGetProperty("include_size", out var sizeEl) || sizeEl.GetBoolean();
|
||||
var topN = args.SafeTryGetProperty("top_n", out var topEl) && topEl.TryGetInt32(out var n) ? n : 50;
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
|
||||
@@ -48,11 +48,11 @@ public class FileWriteTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
||||
var content = args.GetProperty("content").GetString() ?? "";
|
||||
var encName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
|
||||
var append = args.TryGetProperty("append", out var appEl) && appEl.GetBoolean();
|
||||
var mkDirs = !args.TryGetProperty("create_dirs", out var mkEl) || mkEl.GetBoolean();
|
||||
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
|
||||
var content = args.GetProperty("content").SafeGetString() ?? "";
|
||||
var encName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
|
||||
var append = args.SafeTryGetProperty("append", out var appEl) && appEl.GetBoolean();
|
||||
var mkDirs = !args.SafeTryGetProperty("create_dirs", out var mkEl) || mkEl.GetBoolean();
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(rawPath, context.WorkFolder);
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ public class FolderMapTool : IAgentTool
|
||||
Properties = new()
|
||||
{
|
||||
["path"] = new() { Type = "string", Description = "Subdirectory to map. Optional, defaults to work folder root." },
|
||||
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 3." },
|
||||
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: true." },
|
||||
["depth"] = new() { Type = "integer", Description = "Maximum depth to traverse (1-10). Default: 2." },
|
||||
["include_files"] = new() { Type = "boolean", Description = "Whether to include files. Default: false for conservative first-pass exploration." },
|
||||
["pattern"] = new() { Type = "string", Description = "Single file extension filter (e.g. '.cs', '.py'). Optional. Use 'extensions' for multiple extensions." },
|
||||
["extensions"] = new()
|
||||
{
|
||||
@@ -52,34 +52,34 @@ public class FolderMapTool : IAgentTool
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
// ── path ──────────────────────────────────────────────────────────
|
||||
var subPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
var subPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
|
||||
|
||||
// ── depth ─────────────────────────────────────────────────────────
|
||||
var depth = 3;
|
||||
if (args.TryGetProperty("depth", out var d))
|
||||
var depth = 2;
|
||||
if (args.SafeTryGetProperty("depth", out var d))
|
||||
{
|
||||
if (d.ValueKind == JsonValueKind.Number) depth = d.GetInt32();
|
||||
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.GetString(), out var dv)) depth = dv;
|
||||
else if (d.ValueKind == JsonValueKind.String && int.TryParse(d.SafeGetString(), out var dv)) depth = dv;
|
||||
}
|
||||
if (depth < 1) depth = 1;
|
||||
var maxDepth = Math.Min(depth, 10);
|
||||
|
||||
// ── include_files ─────────────────────────────────────────────────
|
||||
var includeFiles = true;
|
||||
if (args.TryGetProperty("include_files", out var inc))
|
||||
var includeFiles = false;
|
||||
if (args.SafeTryGetProperty("include_files", out var inc))
|
||||
{
|
||||
if (inc.ValueKind == JsonValueKind.True || inc.ValueKind == JsonValueKind.False)
|
||||
includeFiles = inc.GetBoolean();
|
||||
else
|
||||
includeFiles = !string.Equals(inc.GetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||
includeFiles = !string.Equals(inc.SafeGetString(), "false", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ── extensions / pattern ──────────────────────────────────────────
|
||||
HashSet<string>? extSet = null;
|
||||
if (args.TryGetProperty("extensions", out var extsEl) && extsEl.ValueKind == JsonValueKind.Array)
|
||||
if (args.SafeTryGetProperty("extensions", out var extsEl) && extsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var list = extsEl.EnumerateArray()
|
||||
.Select(e => e.GetString() ?? "")
|
||||
.Select(e => e.SafeGetString() ?? "")
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s.StartsWith('.') ? s : "." + s)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -88,34 +88,34 @@ public class FolderMapTool : IAgentTool
|
||||
// Fall back to single pattern if extensions not provided
|
||||
string extFilter = "";
|
||||
if (extSet == null)
|
||||
extFilter = args.TryGetProperty("pattern", out var pat) ? pat.GetString() ?? "" : "";
|
||||
extFilter = args.SafeTryGetProperty("pattern", out var pat) ? pat.SafeGetString() ?? "" : "";
|
||||
|
||||
// ── sort_by ───────────────────────────────────────────────────────
|
||||
var sortBy = args.TryGetProperty("sort_by", out var sb2) ? sb2.GetString() ?? "name" : "name";
|
||||
var sortBy = args.SafeTryGetProperty("sort_by", out var sb2) ? sb2.SafeGetString() ?? "name" : "name";
|
||||
if (sortBy != "size" && sortBy != "modified") sortBy = "name";
|
||||
|
||||
// ── show_dir_sizes ────────────────────────────────────────────────
|
||||
var showDirSizes = false;
|
||||
if (args.TryGetProperty("show_dir_sizes", out var sds))
|
||||
if (args.SafeTryGetProperty("show_dir_sizes", out var sds))
|
||||
{
|
||||
if (sds.ValueKind == JsonValueKind.True || sds.ValueKind == JsonValueKind.False)
|
||||
showDirSizes = sds.GetBoolean();
|
||||
else
|
||||
showDirSizes = string.Equals(sds.GetString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
showDirSizes = string.Equals(sds.SafeGetString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ── modified_after ────────────────────────────────────────────────
|
||||
DateTime? modifiedAfter = null;
|
||||
if (args.TryGetProperty("modified_after", out var maEl) && maEl.ValueKind == JsonValueKind.String)
|
||||
if (args.SafeTryGetProperty("modified_after", out var maEl) && maEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTime.TryParse(maEl.GetString(), out var mdt))
|
||||
if (DateTime.TryParse(maEl.SafeGetString(), out var mdt))
|
||||
modifiedAfter = mdt;
|
||||
}
|
||||
|
||||
// ── max_file_size ─────────────────────────────────────────────────
|
||||
long? maxFileSizeBytes = null;
|
||||
if (args.TryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
|
||||
maxFileSizeBytes = ParseSizeString(mfsEl.GetString() ?? "");
|
||||
if (args.SafeTryGetProperty("max_file_size", out var mfsEl) && mfsEl.ValueKind == JsonValueKind.String)
|
||||
maxFileSizeBytes = ParseSizeString(mfsEl.SafeGetString() ?? "");
|
||||
|
||||
// ── resolve base directory ────────────────────────────────────────
|
||||
var baseDir = string.IsNullOrEmpty(subPath)
|
||||
|
||||
@@ -36,9 +36,9 @@ public class FormatConvertTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var source = args.GetProperty("source").GetString() ?? "";
|
||||
var target = args.GetProperty("target").GetString() ?? "";
|
||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
|
||||
var source = args.GetProperty("source").SafeGetString() ?? "";
|
||||
var target = args.GetProperty("target").SafeGetString() ?? "";
|
||||
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "modern" : "modern";
|
||||
|
||||
var srcPath = FileReadTool.ResolvePath(source, context.WorkFolder);
|
||||
var tgtPath = FileReadTool.ResolvePath(target, context.WorkFolder);
|
||||
|
||||
@@ -51,10 +51,10 @@ public class GitTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("action", out var actionEl))
|
||||
if (!args.SafeTryGetProperty("action", out var actionEl))
|
||||
return ToolResult.Fail("action이 필요합니다.");
|
||||
var action = actionEl.GetString() ?? "status";
|
||||
var extraArgs = args.TryGetProperty("args", out var a) ? a.GetString() ?? "" : "";
|
||||
var action = actionEl.SafeGetString() ?? "status";
|
||||
var extraArgs = args.SafeTryGetProperty("args", out var a) ? a.SafeGetString() ?? "" : "";
|
||||
|
||||
var workDir = context.WorkFolder;
|
||||
if (string.IsNullOrEmpty(workDir) || !Directory.Exists(workDir))
|
||||
|
||||
@@ -27,12 +27,12 @@ public class GlobTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
||||
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
var sortBy = args.TryGetProperty("sort_by", out var sb) ? sb.GetString() ?? "name" : "name";
|
||||
var maxResults = args.TryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
|
||||
var includeHidden = args.TryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
|
||||
var excludePattern = args.TryGetProperty("exclude_pattern", out var ep) ? ep.GetString() ?? "" : "";
|
||||
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
|
||||
var searchPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
|
||||
var sortBy = args.SafeTryGetProperty("sort_by", out var sb) ? sb.SafeGetString() ?? "name" : "name";
|
||||
var maxResults = args.SafeTryGetProperty("max_results", out var mr) ? Math.Clamp(mr.GetInt32(), 1, 2000) : 200;
|
||||
var includeHidden = args.SafeTryGetProperty("include_hidden", out var ih) && ih.GetBoolean();
|
||||
var excludePattern = args.SafeTryGetProperty("exclude_pattern", out var ep) ? ep.SafeGetString() ?? "" : "";
|
||||
|
||||
var baseDir = string.IsNullOrEmpty(searchPath)
|
||||
? context.WorkFolder
|
||||
|
||||
@@ -33,20 +33,20 @@ public class GrepTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
||||
var searchPath = args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||
var globFilter = args.TryGetProperty("glob", out var g) ? g.GetString() ?? "" : "";
|
||||
var caseSensitive = args.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
|
||||
var maxMatches = args.TryGetProperty("max_matches", out var mm) ? Math.Clamp(mm.GetInt32(), 1, 500) : 100;
|
||||
var filesOnly = args.TryGetProperty("files_only", out var fo) && fo.GetBoolean();
|
||||
var wholeWord = args.TryGetProperty("whole_word", out var ww) && ww.GetBoolean();
|
||||
var invert = args.TryGetProperty("invert", out var inv) && inv.GetBoolean();
|
||||
var maxFileSizeKb = args.TryGetProperty("max_file_size_kb", out var mfs) ? Math.Max(1, mfs.GetInt32()) : 1000;
|
||||
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
|
||||
var searchPath = args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : "";
|
||||
var globFilter = args.SafeTryGetProperty("glob", out var g) ? g.SafeGetString() ?? "" : "";
|
||||
var caseSensitive = args.SafeTryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
|
||||
var maxMatches = args.SafeTryGetProperty("max_matches", out var mm) ? Math.Clamp(mm.GetInt32(), 1, 500) : 100;
|
||||
var filesOnly = args.SafeTryGetProperty("files_only", out var fo) && fo.GetBoolean();
|
||||
var wholeWord = args.SafeTryGetProperty("whole_word", out var ww) && ww.GetBoolean();
|
||||
var invert = args.SafeTryGetProperty("invert", out var inv) && inv.GetBoolean();
|
||||
var maxFileSizeKb = args.SafeTryGetProperty("max_file_size_kb", out var mfs) ? Math.Max(1, mfs.GetInt32()) : 1000;
|
||||
|
||||
// context_lines is the base for both sides; before/after_lines override individually
|
||||
var contextLines = args.TryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 10) : 0;
|
||||
var beforeLines = args.TryGetProperty("before_lines", out var bl) ? Math.Clamp(bl.GetInt32(), 0, 10) : contextLines;
|
||||
var afterLines = args.TryGetProperty("after_lines", out var al) ? Math.Clamp(al.GetInt32(), 0, 10) : contextLines;
|
||||
var contextLines = args.SafeTryGetProperty("context_lines", out var cl) ? Math.Clamp(cl.GetInt32(), 0, 10) : 0;
|
||||
var beforeLines = args.SafeTryGetProperty("before_lines", out var bl) ? Math.Clamp(bl.GetInt32(), 0, 10) : contextLines;
|
||||
var afterLines = args.SafeTryGetProperty("after_lines", out var al) ? Math.Clamp(al.GetInt32(), 0, 10) : contextLines;
|
||||
|
||||
var baseDir = string.IsNullOrEmpty(searchPath)
|
||||
? context.WorkFolder
|
||||
|
||||
@@ -41,9 +41,9 @@ public class HashTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var mode = args.GetProperty("mode").GetString() ?? "text";
|
||||
var input = args.GetProperty("input").GetString() ?? "";
|
||||
var algo = args.TryGetProperty("algorithm", out var a) ? a.GetString() ?? "sha256" : "sha256";
|
||||
var mode = args.GetProperty("mode").SafeGetString() ?? "text";
|
||||
var input = args.GetProperty("input").SafeGetString() ?? "";
|
||||
var algo = args.SafeTryGetProperty("algorithm", out var a) ? a.SafeGetString() ?? "sha256" : "sha256";
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -67,26 +67,26 @@ public class HtmlSkill : IAgentTool
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
// 필수 파라미터 안전 추출
|
||||
if (!args.TryGetProperty("title", out var titleEl) || titleEl.ValueKind == JsonValueKind.Null)
|
||||
if (!args.SafeTryGetProperty("title", out var titleEl) || titleEl.ValueKind == JsonValueKind.Null)
|
||||
return ToolResult.Fail("필수 파라미터 누락: 'title'");
|
||||
|
||||
// path 미제공 시 title로 자동 생성
|
||||
args.TryGetProperty("path", out var pathEl);
|
||||
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.GetString()))
|
||||
args.SafeTryGetProperty("path", out var pathEl);
|
||||
if (pathEl.ValueKind == JsonValueKind.Null || string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||
pathEl = default; // 아래에서 title 기반으로 생성
|
||||
|
||||
// body와 sections 둘 다 없으면 오류
|
||||
bool hasBody = args.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
|
||||
bool hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
|
||||
bool hasBody = args.SafeTryGetProperty("body", out var bodyEl) && bodyEl.ValueKind != JsonValueKind.Null;
|
||||
bool hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
|
||||
if (!hasBody && !hasSections)
|
||||
return ToolResult.Fail("필수 파라미터 누락: 'body' 또는 'sections' 중 하나는 반드시 제공해야 합니다.");
|
||||
|
||||
var title = titleEl.GetString() ?? "Report";
|
||||
var title = titleEl.SafeGetString() ?? "Report";
|
||||
// path가 없으면 title에서 안전한 파일명 생성
|
||||
string path;
|
||||
if (pathEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
||||
if (pathEl.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||
{
|
||||
path = pathEl.GetString()!;
|
||||
path = pathEl.SafeGetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -95,14 +95,14 @@ public class HtmlSkill : IAgentTool
|
||||
if (string.IsNullOrWhiteSpace(safeName)) safeName = "report";
|
||||
path = safeName + ".html";
|
||||
}
|
||||
var body = hasBody ? (bodyEl.GetString() ?? "") : "";
|
||||
var customStyle = args.TryGetProperty("style", out var s) ? s.GetString() : null;
|
||||
var mood = args.TryGetProperty("mood", out var m) ? m.GetString() ?? "modern" : "modern";
|
||||
var useToc = args.TryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
|
||||
var useNumbered = args.TryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
|
||||
var usePrint = args.TryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
|
||||
var hasCover = args.TryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
|
||||
var accentColor = args.TryGetProperty("accent_color", out var accentEl) ? accentEl.GetString() : null;
|
||||
var body = hasBody ? (bodyEl.SafeGetString() ?? "") : "";
|
||||
var customStyle = args.SafeTryGetProperty("style", out var s) ? s.SafeGetString() : null;
|
||||
var mood = args.SafeTryGetProperty("mood", out var m) ? m.SafeGetString() ?? "modern" : "modern";
|
||||
var useToc = args.SafeTryGetProperty("toc", out var tocVal) && tocVal.ValueKind == JsonValueKind.True;
|
||||
var useNumbered = args.SafeTryGetProperty("numbered", out var numVal) && numVal.ValueKind == JsonValueKind.True;
|
||||
var usePrint = args.SafeTryGetProperty("print", out var printVal) && printVal.ValueKind == JsonValueKind.True;
|
||||
var hasCover = args.SafeTryGetProperty("cover", out var coverVal) && coverVal.ValueKind == JsonValueKind.Object;
|
||||
var accentColor = args.SafeTryGetProperty("accent_color", out var accentEl) ? accentEl.SafeGetString() : null;
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
|
||||
@@ -187,8 +187,16 @@ public class HtmlSkill : IAgentTool
|
||||
if (!string.IsNullOrEmpty(tocHtml))
|
||||
sb.AppendLine(tocHtml);
|
||||
|
||||
// 본문
|
||||
sb.AppendLine(body);
|
||||
// 본문 — table 태그에 반응형 래퍼 자동 추가
|
||||
var wrappedBody = System.Text.RegularExpressions.Regex.Replace(
|
||||
body,
|
||||
@"(<table\b)",
|
||||
"<div style=\"overflow-x:auto;margin:0 -4px;padding:0 4px\">$1");
|
||||
wrappedBody = System.Text.RegularExpressions.Regex.Replace(
|
||||
wrappedBody,
|
||||
@"(</table>)",
|
||||
"$1</div>");
|
||||
sb.AppendLine(wrappedBody);
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
sb.AppendLine("</body>");
|
||||
@@ -225,8 +233,8 @@ public class HtmlSkill : IAgentTool
|
||||
var sb = new StringBuilder();
|
||||
foreach (var section in sections.EnumerateArray())
|
||||
{
|
||||
if (!section.TryGetProperty("type", out var typeEl)) continue;
|
||||
var type = typeEl.GetString() ?? "";
|
||||
if (!section.SafeTryGetProperty("type", out var typeEl)) continue;
|
||||
var type = typeEl.SafeGetString() ?? "";
|
||||
|
||||
switch (type.ToLowerInvariant())
|
||||
{
|
||||
@@ -267,22 +275,22 @@ public class HtmlSkill : IAgentTool
|
||||
|
||||
private static string RenderHeading(JsonElement s)
|
||||
{
|
||||
var level = s.TryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2;
|
||||
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
|
||||
var level = s.SafeTryGetProperty("level", out var lv) ? Math.Clamp(lv.GetInt32(), 1, 4) : 2;
|
||||
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
|
||||
return $"<h{level}>{Escape(text)}</h{level}>";
|
||||
}
|
||||
|
||||
private static string RenderParagraph(JsonElement s)
|
||||
{
|
||||
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
|
||||
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
|
||||
return $"<p>{MarkdownToHtml(text)}</p>";
|
||||
}
|
||||
|
||||
private static string RenderCallout(JsonElement s)
|
||||
{
|
||||
var style = s.TryGetProperty("style", out var st) ? st.GetString() ?? "info" : "info";
|
||||
var title = s.TryGetProperty("title", out var ti) ? ti.GetString() ?? "" : "";
|
||||
var text = s.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "";
|
||||
var style = s.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "info" : "info";
|
||||
var title = s.SafeTryGetProperty("title", out var ti) ? ti.SafeGetString() ?? "" : "";
|
||||
var text = s.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "";
|
||||
var icon = style switch { "warning" => "⚠️", "tip" => "💡", "danger" => "🚨", _ => "ℹ️" };
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"<div class=\"callout-{style}\">");
|
||||
@@ -299,15 +307,15 @@ public class HtmlSkill : IAgentTool
|
||||
sb.AppendLine("<div style=\"overflow-x:auto\">");
|
||||
sb.AppendLine("<table>");
|
||||
|
||||
if (s.TryGetProperty("headers", out var headers) && headers.ValueKind == JsonValueKind.Array)
|
||||
if (s.SafeTryGetProperty("headers", out var headers) && headers.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
sb.AppendLine("<thead><tr>");
|
||||
foreach (var h in headers.EnumerateArray())
|
||||
sb.Append($"<th>{Escape(h.GetString() ?? "")}</th>");
|
||||
sb.Append($"<th>{Escape(h.SafeGetString() ?? "")}</th>");
|
||||
sb.AppendLine("</tr></thead>");
|
||||
}
|
||||
|
||||
if (s.TryGetProperty("rows", out var rows) && rows.ValueKind == JsonValueKind.Array)
|
||||
if (s.SafeTryGetProperty("rows", out var rows) && rows.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
sb.AppendLine("<tbody>");
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
@@ -315,7 +323,7 @@ public class HtmlSkill : IAgentTool
|
||||
sb.Append("<tr>");
|
||||
if (row.ValueKind == JsonValueKind.Array)
|
||||
foreach (var cell in row.EnumerateArray())
|
||||
sb.Append($"<td>{MarkdownToHtml(cell.GetString() ?? "")}</td>");
|
||||
sb.Append($"<td>{MarkdownToHtml(cell.SafeGetString() ?? "")}</td>");
|
||||
sb.AppendLine("</tr>");
|
||||
}
|
||||
sb.AppendLine("</tbody>");
|
||||
@@ -328,19 +336,19 @@ public class HtmlSkill : IAgentTool
|
||||
|
||||
private static string RenderChart(JsonElement s, string? accentColor)
|
||||
{
|
||||
var kind = s.TryGetProperty("kind", out var k) ? k.GetString() ?? "bar" : "bar";
|
||||
var chartTitle = s.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
|
||||
var kind = s.SafeTryGetProperty("kind", out var k) ? k.SafeGetString() ?? "bar" : "bar";
|
||||
var chartTitle = s.SafeTryGetProperty("title", out var t) ? t.SafeGetString() ?? "" : "";
|
||||
|
||||
if (!s.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
|
||||
if (!s.SafeTryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
|
||||
return "";
|
||||
|
||||
var items = new List<(string label, double value, string color)>();
|
||||
double maxVal = 0;
|
||||
foreach (var item in data.EnumerateArray())
|
||||
{
|
||||
var label = item.TryGetProperty("label", out var lb) ? lb.GetString() ?? "" : "";
|
||||
var value = item.TryGetProperty("value", out var vl) ? vl.GetDouble() : 0;
|
||||
var color = item.TryGetProperty("color", out var cl) ? cl.GetString() ?? "#2E75B6" : "#2E75B6";
|
||||
var label = item.SafeTryGetProperty("label", out var lb) ? lb.SafeGetString() ?? "" : "";
|
||||
var value = item.SafeTryGetProperty("value", out var vl) ? vl.GetDouble() : 0;
|
||||
var color = item.SafeTryGetProperty("color", out var cl) ? cl.SafeGetString() ?? "#2E75B6" : "#2E75B6";
|
||||
items.Add((label, value, color));
|
||||
if (value > maxVal) maxVal = value;
|
||||
}
|
||||
@@ -399,17 +407,17 @@ public class HtmlSkill : IAgentTool
|
||||
|
||||
private static string RenderCards(JsonElement s)
|
||||
{
|
||||
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||
return "";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<div class=\"cards-grid\" style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;margin:1rem 0\">");
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var cardTitle = item.TryGetProperty("title", out var ti) ? ti.GetString() ?? "" : "";
|
||||
var cardBody = item.TryGetProperty("body", out var bd) ? bd.GetString() ?? "" : "";
|
||||
var badge = item.TryGetProperty("badge", out var bg) ? bg.GetString() : null;
|
||||
var icon = item.TryGetProperty("icon", out var ic) ? ic.GetString() : null;
|
||||
var cardTitle = item.SafeTryGetProperty("title", out var ti) ? ti.SafeGetString() ?? "" : "";
|
||||
var cardBody = item.SafeTryGetProperty("body", out var bd) ? bd.SafeGetString() ?? "" : "";
|
||||
var badge = item.SafeTryGetProperty("badge", out var bg) ? bg.SafeGetString() : null;
|
||||
var icon = item.SafeTryGetProperty("icon", out var ic) ? ic.SafeGetString() : null;
|
||||
|
||||
sb.AppendLine("<div class=\"card\" style=\"border:1px solid #e5e7eb;border-radius:8px;padding:1.1rem;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.07)\">");
|
||||
sb.Append("<div style=\"display:flex;align-items:center;gap:8px;margin-bottom:6px\">");
|
||||
@@ -429,8 +437,8 @@ public class HtmlSkill : IAgentTool
|
||||
|
||||
private static string RenderList(JsonElement s)
|
||||
{
|
||||
var style = s.TryGetProperty("style", out var st) ? st.GetString() ?? "bullet" : "bullet";
|
||||
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||
var style = s.SafeTryGetProperty("style", out var st) ? st.SafeGetString() ?? "bullet" : "bullet";
|
||||
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||
return "";
|
||||
|
||||
var tag = style == "number" ? "ol" : "ul";
|
||||
@@ -442,7 +450,7 @@ public class HtmlSkill : IAgentTool
|
||||
bool inSubList = false;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var text = item.GetString() ?? "";
|
||||
var text = item.SafeGetString() ?? "";
|
||||
bool isSub = text.StartsWith(" ");
|
||||
if (isSub && !inSubList)
|
||||
{
|
||||
@@ -464,8 +472,8 @@ public class HtmlSkill : IAgentTool
|
||||
|
||||
private static string RenderQuote(JsonElement s)
|
||||
{
|
||||
var text = s.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
|
||||
var author = s.TryGetProperty("author", out var a) ? a.GetString() : null;
|
||||
var text = s.SafeTryGetProperty("text", out var t) ? t.SafeGetString() ?? "" : "";
|
||||
var author = s.SafeTryGetProperty("author", out var a) ? a.SafeGetString() : null;
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<blockquote style=\"border-left:4px solid #d1d5db;margin:1.5rem 0;padding:.75rem 1.25rem;background:#f9fafb;border-radius:0 6px 6px 0\">");
|
||||
sb.AppendLine($"<p style=\"margin:0;font-style:italic;color:#374151\">{MarkdownToHtml(text)}</p>");
|
||||
@@ -477,17 +485,17 @@ public class HtmlSkill : IAgentTool
|
||||
|
||||
private static string RenderKpi(JsonElement s)
|
||||
{
|
||||
if (!s.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||
if (!s.SafeTryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array)
|
||||
return "";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<div class=\"kpi-grid\" style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem;margin:1.25rem 0\">");
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var label = item.TryGetProperty("label", out var lb) ? lb.GetString() ?? "" : "";
|
||||
var value = item.TryGetProperty("value", out var vl) ? vl.GetString() ?? "" : "";
|
||||
var change = item.TryGetProperty("change", out var ch) ? ch.GetString() : null;
|
||||
var positive = item.TryGetProperty("positive", out var pos) ? pos.GetBoolean() : true;
|
||||
var label = item.SafeTryGetProperty("label", out var lb) ? lb.SafeGetString() ?? "" : "";
|
||||
var value = item.SafeTryGetProperty("value", out var vl) ? vl.SafeGetString() ?? "" : "";
|
||||
var change = item.SafeTryGetProperty("change", out var ch) ? ch.SafeGetString() : null;
|
||||
var positive = item.SafeTryGetProperty("positive", out var pos) ? pos.GetBoolean() : true;
|
||||
var changeColor = positive ? "#16a34a" : "#dc2626";
|
||||
var changeArrow = positive ? "▲" : "▼";
|
||||
|
||||
@@ -515,6 +523,9 @@ public class HtmlSkill : IAgentTool
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return text;
|
||||
|
||||
// 0. LLM이 이미 삽입한 <br> / <br/> 태그를 보존 — 이스케이프 전에 플레이스홀더로 치환
|
||||
text = Regex.Replace(text, @"<br\s*/?>", "\x00BR\x00");
|
||||
|
||||
// 1. HTML 이스케이프 (먼저 처리해서 XSS 방지)
|
||||
text = text.Replace("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
@@ -535,6 +546,9 @@ public class HtmlSkill : IAgentTool
|
||||
// 6. newline → <br>
|
||||
text = text.Replace("\n", "<br>");
|
||||
|
||||
// 7. 보존된 <br> 플레이스홀더를 복원
|
||||
text = text.Replace("\x00BR\x00", "<br>");
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -685,17 +699,18 @@ public class HtmlSkill : IAgentTool
|
||||
/// <summary>cover 객체에서 커버 페이지 HTML 생성</summary>
|
||||
private static string GenerateCover(JsonElement cover, string fallbackTitle)
|
||||
{
|
||||
var coverTitle = cover.TryGetProperty("title", out var ct) ? ct.GetString() ?? fallbackTitle : fallbackTitle;
|
||||
var subtitle = cover.TryGetProperty("subtitle", out var sub) ? sub.GetString() ?? "" : "";
|
||||
var author = cover.TryGetProperty("author", out var auth) ? auth.GetString() ?? "" : "";
|
||||
var date = cover.TryGetProperty("date", out var dt) ? dt.GetString() ?? DateTime.Now.ToString("yyyy-MM-dd") : DateTime.Now.ToString("yyyy-MM-dd");
|
||||
var gradient = cover.TryGetProperty("gradient", out var grad) ? grad.GetString() : null;
|
||||
var coverTitle = cover.SafeTryGetProperty("title", out var ct) ? ct.SafeGetString() ?? fallbackTitle : fallbackTitle;
|
||||
var subtitle = cover.SafeTryGetProperty("subtitle", out var sub) ? sub.SafeGetString() ?? "" : "";
|
||||
var author = cover.SafeTryGetProperty("author", out var auth) ? auth.SafeGetString() ?? "" : "";
|
||||
var date = cover.SafeTryGetProperty("date", out var dt) ? dt.SafeGetString() ?? DateTime.Now.ToString("yyyy-MM-dd") : DateTime.Now.ToString("yyyy-MM-dd");
|
||||
var gradient = cover.SafeTryGetProperty("gradient", out var grad) ? grad.SafeGetString() : null;
|
||||
|
||||
var styleAttr = "";
|
||||
if (!string.IsNullOrEmpty(gradient) && gradient.Contains(','))
|
||||
{
|
||||
var colors = gradient.Split(',');
|
||||
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
|
||||
if (colors.Length >= 2)
|
||||
styleAttr = $" style=\"background: linear-gradient(135deg, {colors[0].Trim()} 0%, {colors[1].Trim()} 100%)\"";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
@@ -58,11 +58,11 @@ public class HttpTool : IAgentTool
|
||||
if (AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode))
|
||||
return ToolResult.Fail("사내모드에서는 HTTP 도구 실행이 차단됩니다. operationMode=external에서만 사용할 수 있습니다.");
|
||||
|
||||
var method = args.GetProperty("method").GetString()?.ToUpperInvariant() ?? "GET";
|
||||
var url = args.GetProperty("url").GetString() ?? "";
|
||||
var body = args.TryGetProperty("body", out var b) ? b.GetString() ?? "" : "";
|
||||
var headers = args.TryGetProperty("headers", out var h) ? h.GetString() ?? "" : "";
|
||||
var timeout = args.TryGetProperty("timeout", out var t) ? int.TryParse(t.GetString(), out var ts) ? Math.Min(ts, 120) : 30 : 30;
|
||||
var method = (args.SafeGetProperty("method")?.SafeGetString() ?? "GET").ToUpperInvariant();
|
||||
var url = args.SafeGetProperty("url")?.SafeGetString() ?? "";
|
||||
var body = args.SafeTryGetProperty("body", out var b) ? b.SafeGetString() ?? "" : "";
|
||||
var headers = args.SafeTryGetProperty("headers", out var h) ? h.SafeGetString() ?? "" : "";
|
||||
var timeout = args.SafeTryGetProperty("timeout", out var t) ? Math.Min(t.SafeGetInt32(30), 120) : 30;
|
||||
|
||||
// 보안: 허용된 호스트만
|
||||
if (!IsAllowedHost(url))
|
||||
@@ -77,8 +77,8 @@ public class HttpTool : IAgentTool
|
||||
if (!string.IsNullOrEmpty(headers))
|
||||
{
|
||||
using var headerDoc = JsonDocument.Parse(headers);
|
||||
foreach (var prop in headerDoc.RootElement.EnumerateObject())
|
||||
request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.GetString());
|
||||
foreach (var prop in headerDoc.RootElement.SafeEnumerateObject())
|
||||
request.Headers.TryAddWithoutValidation(prop.Name, prop.Value.SafeGetString());
|
||||
}
|
||||
|
||||
// 본문 설정
|
||||
|
||||
@@ -52,10 +52,10 @@ public class ImageAnalyzeTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var imagePath = args.GetProperty("image_path").GetString() ?? "";
|
||||
var task = args.TryGetProperty("task", out var taskEl) ? taskEl.GetString() ?? "describe" : "describe";
|
||||
var question = args.TryGetProperty("question", out var qEl) ? qEl.GetString() ?? "" : "";
|
||||
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
|
||||
var imagePath = args.GetProperty("image_path").SafeGetString() ?? "";
|
||||
var task = args.SafeTryGetProperty("task", out var taskEl) ? taskEl.SafeGetString() ?? "describe" : "describe";
|
||||
var question = args.SafeTryGetProperty("question", out var qEl) ? qEl.SafeGetString() ?? "" : "";
|
||||
var language = args.SafeTryGetProperty("language", out var langEl) ? langEl.SafeGetString() ?? "ko" : "ko";
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(imagePath, context.WorkFolder);
|
||||
if (!context.IsPathAllowed(fullPath))
|
||||
@@ -88,9 +88,9 @@ public class ImageAnalyzeTool : IAgentTool
|
||||
// 비교 모드: 두 번째 이미지
|
||||
string? compareBase64 = null;
|
||||
string? compareMime = null;
|
||||
if (task == "compare" && args.TryGetProperty("compare_path", out var cpEl))
|
||||
if (task == "compare" && args.SafeTryGetProperty("compare_path", out var cpEl))
|
||||
{
|
||||
var comparePath = FileReadTool.ResolvePath(cpEl.GetString() ?? "", context.WorkFolder);
|
||||
var comparePath = FileReadTool.ResolvePath(cpEl.SafeGetString() ?? "", context.WorkFolder);
|
||||
if (File.Exists(comparePath) && context.IsPathAllowed(comparePath))
|
||||
{
|
||||
var compareBytes = await File.ReadAllBytesAsync(comparePath, ct);
|
||||
|
||||
84
src/AxCopilot/Services/Agent/JsonElementExtensions.cs
Normal file
84
src/AxCopilot/Services/Agent/JsonElementExtensions.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="JsonElement"/> 안전 변환 확장 메서드.
|
||||
/// Qwen 3.5 등 일부 LLM은 문자열 파라미터를 따옴표 없이 숫자로 반환하므로
|
||||
/// <c>GetString()</c> 호출 시 InvalidOperationException이 발생합니다.
|
||||
/// 이 확장을 사용하면 Number/Boolean/Null 등 모든 ValueKind를 문자열로 안전하게 변환합니다.
|
||||
/// </summary>
|
||||
internal static class JsonElementExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// JsonElement를 안전하게 문자열로 변환합니다.
|
||||
/// String이면 GetString(), 그 외(Number, True/False 등)이면 GetRawText()를 반환합니다.
|
||||
/// </summary>
|
||||
public static string? SafeGetString(this JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||
_ => element.GetRawText(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JsonElement를 안전하게 int로 변환합니다.
|
||||
/// Number이면 GetInt32(), String이면 int.TryParse, 그 외이면 defaultValue를 반환합니다.
|
||||
/// </summary>
|
||||
public static int SafeGetInt32(this JsonElement element, int defaultValue = 0)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number => element.GetInt32(),
|
||||
JsonValueKind.String => int.TryParse(element.GetString(), out var v) ? v : defaultValue,
|
||||
_ => defaultValue,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TryGetProperty의 안전 래퍼.
|
||||
/// element가 Object가 아니면 InvalidOperationException을 던지는 대신 false를 반환합니다.
|
||||
/// Qwen 3.5 등 일부 LLM이 Object 대신 String을 반환하는 경우에 대비합니다.
|
||||
/// </summary>
|
||||
public static bool SafeTryGetProperty(this JsonElement element, string propertyName, out JsonElement value)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
return element.TryGetProperty(propertyName, out value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GetProperty의 안전 래퍼.
|
||||
/// element가 Object가 아니거나 속성이 없으면 null을 반환합니다.
|
||||
/// </summary>
|
||||
public static JsonElement? SafeGetProperty(this JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
return element.TryGetProperty(propertyName, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EnumerateObject의 안전 래퍼.
|
||||
/// Object가 아니면 빈 열거자를 반환합니다.
|
||||
/// </summary>
|
||||
public static JsonElement.ObjectEnumerator SafeEnumerateObject(this JsonElement element)
|
||||
{
|
||||
return element.ValueKind == JsonValueKind.Object ? element.EnumerateObject() : default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EnumerateArray의 안전 래퍼.
|
||||
/// Array가 아니면 빈 열거자를 반환합니다.
|
||||
/// </summary>
|
||||
public static JsonElement.ArrayEnumerator SafeEnumerateArray(this JsonElement element)
|
||||
{
|
||||
return element.ValueKind == JsonValueKind.Array ? element.EnumerateArray() : default;
|
||||
}
|
||||
}
|
||||
@@ -54,18 +54,18 @@ public class JsonTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var json = args.GetProperty("json").GetString() ?? "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
var json = args.GetProperty("json").SafeGetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
return Task.FromResult(action switch
|
||||
{
|
||||
"validate" => Validate(json),
|
||||
"format" => Format(json, args.TryGetProperty("minify", out var m) && m.GetString() == "true"),
|
||||
"query" => Query(json, args.TryGetProperty("path", out var p) ? p.GetString() ?? "" : ""),
|
||||
"format" => Format(json, args.SafeTryGetProperty("minify", out var m) && m.SafeGetString() == "true"),
|
||||
"query" => Query(json, args.SafeTryGetProperty("path", out var p) ? p.SafeGetString() ?? "" : ""),
|
||||
"keys" => Keys(json),
|
||||
"convert" => Convert(json, args.TryGetProperty("target_format", out var tf) ? tf.GetString() ?? "csv" : "csv"),
|
||||
"convert" => Convert(json, args.SafeTryGetProperty("target_format", out var tf) ? tf.SafeGetString() ?? "csv" : "csv"),
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
});
|
||||
}
|
||||
@@ -122,7 +122,7 @@ public class JsonTool : IAgentTool
|
||||
}
|
||||
else
|
||||
{
|
||||
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment.Key, out var prop))
|
||||
if (current.ValueKind != JsonValueKind.Object || !current.SafeTryGetProperty(segment.Key, out var prop))
|
||||
return ToolResult.Fail($"Key '{segment.Key}' not found");
|
||||
current = prop;
|
||||
}
|
||||
@@ -130,7 +130,7 @@ public class JsonTool : IAgentTool
|
||||
|
||||
var value = current.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => current.GetString() ?? "",
|
||||
JsonValueKind.String => current.SafeGetString() ?? "",
|
||||
JsonValueKind.Number => current.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
@@ -192,9 +192,9 @@ public class JsonTool : IAgentTool
|
||||
{
|
||||
var values = allKeys.Select(k =>
|
||||
{
|
||||
if (!item.TryGetProperty(k, out var v)) return "\"\"";
|
||||
if (!item.SafeTryGetProperty(k, out var v)) return "\"\"";
|
||||
return v.ValueKind == JsonValueKind.String
|
||||
? $"\"{v.GetString()?.Replace("\"", "\"\"") ?? ""}\""
|
||||
? $"\"{v.SafeGetString()?.Replace("\"", "\"\"") ?? ""}\""
|
||||
: v.GetRawText();
|
||||
});
|
||||
sb.AppendLine(string.Join(",", values));
|
||||
|
||||
@@ -57,10 +57,10 @@ public class LspTool : IAgentTool, IDisposable
|
||||
if (!(app?.SettingsService?.Settings.Llm.Code.EnableLsp ?? true))
|
||||
return ToolResult.Ok("LSP 코드 인텔리전스가 비활성 상태입니다. 설정 → AX Agent → 코드에서 활성화하세요.");
|
||||
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
|
||||
var line = args.TryGetProperty("line", out var l) ? l.GetInt32() : 0;
|
||||
var character = args.TryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
|
||||
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
|
||||
var filePath = args.SafeTryGetProperty("file_path", out var f) ? f.SafeGetString() ?? "" : "";
|
||||
var line = args.SafeTryGetProperty("line", out var l) ? l.GetInt32() : 0;
|
||||
var character = args.SafeTryGetProperty("character", out var ch) ? ch.GetInt32() : 0;
|
||||
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return ToolResult.Fail("file_path가 필요합니다.");
|
||||
|
||||
@@ -57,30 +57,30 @@ public class MarkdownSkill : IAgentTool
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
// ── 필수 파라미터 ──────────────────────────────────────────────────
|
||||
var hasSections = args.TryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
|
||||
var hasContent = args.TryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
|
||||
var hasFrontmatter= args.TryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
|
||||
var hasSections = args.SafeTryGetProperty("sections", out var sectionsEl) && sectionsEl.ValueKind == JsonValueKind.Array;
|
||||
var hasContent = args.SafeTryGetProperty("content", out var contentEl) && contentEl.ValueKind != JsonValueKind.Null;
|
||||
var hasFrontmatter= args.SafeTryGetProperty("frontmatter",out var frontEl) && frontEl.ValueKind == JsonValueKind.Object;
|
||||
|
||||
if (!hasSections && !hasContent)
|
||||
return ToolResult.Fail("필수 파라미터 누락: 'sections' 또는 'content' 중 하나는 반드시 제공해야 합니다.");
|
||||
|
||||
// path 미제공 시 title에서 자동 생성
|
||||
string path;
|
||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
||||
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||
{
|
||||
path = pathEl.GetString()!;
|
||||
path = pathEl.SafeGetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
var baseTitle = (args.TryGetProperty("title", out var autoT) ? autoT.GetString() : null) ?? "document";
|
||||
var baseTitle = (args.SafeTryGetProperty("title", out var autoT) ? autoT.SafeGetString() : null) ?? "document";
|
||||
var safe = System.Text.RegularExpressions.Regex.Replace(baseTitle, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
|
||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||
path = (string.IsNullOrWhiteSpace(safe) ? "document" : safe) + ".md";
|
||||
}
|
||||
var title = args.TryGetProperty("title", out var titleEl) ? titleEl.GetString() : null;
|
||||
var useToc = args.TryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
|
||||
var encodingName = args.TryGetProperty("encoding", out var encEl) ? encEl.GetString() ?? "utf-8" : "utf-8";
|
||||
var title = args.SafeTryGetProperty("title", out var titleEl) ? titleEl.SafeGetString() : null;
|
||||
var useToc = args.SafeTryGetProperty("toc", out var tocEl) && tocEl.ValueKind == JsonValueKind.True;
|
||||
var encodingName = args.SafeTryGetProperty("encoding", out var encEl) ? encEl.SafeGetString() ?? "utf-8" : "utf-8";
|
||||
|
||||
// ── 경로 처리 ──────────────────────────────────────────────────────
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
@@ -109,7 +109,7 @@ public class MarkdownSkill : IAgentTool
|
||||
foreach (var prop in frontEl.EnumerateObject())
|
||||
{
|
||||
var val = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? ""
|
||||
? prop.Value.SafeGetString() ?? ""
|
||||
: prop.Value.ToString();
|
||||
// 값에 특수 문자가 있으면 인용
|
||||
if (val.Contains(':') || val.Contains('#') || val.StartsWith('"'))
|
||||
@@ -143,8 +143,8 @@ public class MarkdownSkill : IAgentTool
|
||||
// 섹션 렌더링
|
||||
foreach (var section in sectionsEl.EnumerateArray())
|
||||
{
|
||||
if (!section.TryGetProperty("type", out var typeEl)) continue;
|
||||
var type = typeEl.GetString()?.ToLowerInvariant() ?? "";
|
||||
if (!section.SafeTryGetProperty("type", out var typeEl)) continue;
|
||||
var type = typeEl.SafeGetString()?.ToLowerInvariant() ?? "";
|
||||
|
||||
switch (type)
|
||||
{
|
||||
@@ -187,7 +187,7 @@ public class MarkdownSkill : IAgentTool
|
||||
if (useToc)
|
||||
{
|
||||
// content에서 헤딩 파싱하여 TOC 생성
|
||||
var raw = contentEl.GetString() ?? "";
|
||||
var raw = contentEl.SafeGetString() ?? "";
|
||||
var headings = ParseHeadingsFromContent(raw);
|
||||
if (headings.Count > 0)
|
||||
{
|
||||
@@ -195,7 +195,7 @@ public class MarkdownSkill : IAgentTool
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
sb.Append(contentEl.GetString() ?? "");
|
||||
sb.Append(contentEl.SafeGetString() ?? "");
|
||||
}
|
||||
|
||||
// ── 파일 쓰기 ──────────────────────────────────────────────────
|
||||
@@ -221,9 +221,9 @@ public class MarkdownSkill : IAgentTool
|
||||
var list = new List<(int, string)>();
|
||||
foreach (var s in sections.EnumerateArray())
|
||||
{
|
||||
if (!s.TryGetProperty("type", out var t) || t.GetString() != "heading") continue;
|
||||
var level = s.TryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
|
||||
var text = s.TryGetProperty("text", out var xEl) ? xEl.GetString() ?? "" : "";
|
||||
if (!s.SafeTryGetProperty("type", out var t) || t.SafeGetString() != "heading") continue;
|
||||
var level = s.SafeTryGetProperty("level", out var lEl) ? lEl.GetInt32() : 2;
|
||||
var text = s.SafeTryGetProperty("text", out var xEl) ? xEl.SafeGetString() ?? "" : "";
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
list.Add((Math.Clamp(level, 1, 6), text));
|
||||
}
|
||||
@@ -273,15 +273,15 @@ public class MarkdownSkill : IAgentTool
|
||||
|
||||
private static void RenderHeading(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
var level = s.TryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
|
||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
||||
var level = s.SafeTryGetProperty("level", out var lEl) ? Math.Clamp(lEl.GetInt32(), 1, 6) : 2;
|
||||
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
||||
sb.AppendLine($"{new string('#', level)} {text}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
private static void RenderParagraph(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
||||
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
sb.AppendLine(text);
|
||||
@@ -291,12 +291,12 @@ public class MarkdownSkill : IAgentTool
|
||||
|
||||
private static void RenderTable(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
if (!s.TryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
|
||||
if (!s.SafeTryGetProperty("headers", out var headersEl) || headersEl.ValueKind != JsonValueKind.Array)
|
||||
return;
|
||||
if (!s.TryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
||||
if (!s.SafeTryGetProperty("rows", out var rowsEl) || rowsEl.ValueKind != JsonValueKind.Array)
|
||||
return;
|
||||
|
||||
var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.GetString() ?? "")).ToList();
|
||||
var headers = headersEl.EnumerateArray().Select(h => EscapeTableCell(h.SafeGetString() ?? "")).ToList();
|
||||
if (headers.Count == 0) return;
|
||||
|
||||
// 헤더 행
|
||||
@@ -322,14 +322,14 @@ public class MarkdownSkill : IAgentTool
|
||||
|
||||
private static void RenderList(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
if (!s.TryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array)
|
||||
if (!s.SafeTryGetProperty("items", out var itemsEl) || itemsEl.ValueKind != JsonValueKind.Array)
|
||||
return;
|
||||
|
||||
var ordered = s.TryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True;
|
||||
var ordered = s.SafeTryGetProperty("ordered", out var ordEl) && ordEl.ValueKind == JsonValueKind.True;
|
||||
int idx = 1;
|
||||
foreach (var item in itemsEl.EnumerateArray())
|
||||
{
|
||||
var text = item.ValueKind == JsonValueKind.String ? item.GetString() ?? "" : item.ToString();
|
||||
var text = item.ValueKind == JsonValueKind.String ? item.SafeGetString() ?? "" : item.ToString();
|
||||
// 들여쓰기 보존 (앞에 공백/탭이 있으면 그대로)
|
||||
var prefix = ordered ? $"{idx++}. " : "- ";
|
||||
if (text.StartsWith(" ") || text.StartsWith("\t"))
|
||||
@@ -342,8 +342,8 @@ public class MarkdownSkill : IAgentTool
|
||||
|
||||
private static void RenderCallout(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
var style = s.TryGetProperty("style", out var stEl) ? stEl.GetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
|
||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
||||
var style = s.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString()?.ToUpperInvariant() ?? "INFO" : "INFO";
|
||||
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
||||
|
||||
// 표준 GitHub/Obsidian 콜아웃 형식
|
||||
sb.AppendLine($"> [!{style}]");
|
||||
@@ -354,8 +354,8 @@ public class MarkdownSkill : IAgentTool
|
||||
|
||||
private static void RenderCode(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
var lang = s.TryGetProperty("language", out var lEl) ? lEl.GetString() ?? "" : "";
|
||||
var code = s.TryGetProperty("code", out var cEl) ? cEl.GetString() ?? "" : "";
|
||||
var lang = s.SafeTryGetProperty("language", out var lEl) ? lEl.SafeGetString() ?? "" : "";
|
||||
var code = s.SafeTryGetProperty("code", out var cEl) ? cEl.SafeGetString() ?? "" : "";
|
||||
|
||||
sb.AppendLine($"```{lang}");
|
||||
sb.AppendLine(code);
|
||||
@@ -365,8 +365,8 @@ public class MarkdownSkill : IAgentTool
|
||||
|
||||
private static void RenderQuote(StringBuilder sb, JsonElement s)
|
||||
{
|
||||
var text = s.TryGetProperty("text", out var tEl) ? tEl.GetString() ?? "" : "";
|
||||
var author = s.TryGetProperty("author", out var aEl) ? aEl.GetString() : null;
|
||||
var text = s.SafeTryGetProperty("text", out var tEl) ? tEl.SafeGetString() ?? "" : "";
|
||||
var author = s.SafeTryGetProperty("author", out var aEl) ? aEl.SafeGetString() : null;
|
||||
|
||||
foreach (var line in text.Split('\n'))
|
||||
sb.AppendLine($"> {line}");
|
||||
|
||||
@@ -32,8 +32,8 @@ public class MathTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var expression = args.GetProperty("expression").GetString() ?? "";
|
||||
var precision = args.TryGetProperty("precision", out var p) ? p.GetInt32() : 6;
|
||||
var expression = args.GetProperty("expression").SafeGetString() ?? "";
|
||||
var precision = args.SafeTryGetProperty("precision", out var p) ? p.GetInt32() : 6;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
return Task.FromResult(ToolResult.Fail("수식이 비어 있습니다."));
|
||||
|
||||
@@ -29,8 +29,8 @@ public class McpListResourcesTool : IAgentTool
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var serverName = args.ValueKind == JsonValueKind.Object &&
|
||||
args.TryGetProperty("server_name", out var serverProp)
|
||||
? serverProp.GetString() ?? ""
|
||||
args.SafeTryGetProperty("server_name", out var serverProp)
|
||||
? serverProp.SafeGetString() ?? ""
|
||||
: "";
|
||||
|
||||
var clients = _getClients()
|
||||
|
||||
@@ -35,13 +35,13 @@ public class McpReadResourceTool : IAgentTool
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (args.ValueKind != JsonValueKind.Object ||
|
||||
!args.TryGetProperty("uri", out var uriProp) ||
|
||||
string.IsNullOrWhiteSpace(uriProp.GetString()))
|
||||
!args.SafeTryGetProperty("uri", out var uriProp) ||
|
||||
string.IsNullOrWhiteSpace(uriProp.SafeGetString()))
|
||||
return ToolResult.Fail("uri가 필요합니다.");
|
||||
|
||||
var uri = uriProp.GetString()!;
|
||||
var serverName = args.TryGetProperty("server_name", out var serverProp)
|
||||
? serverProp.GetString() ?? ""
|
||||
var uri = uriProp.SafeGetString()!;
|
||||
var serverName = args.SafeTryGetProperty("server_name", out var serverProp)
|
||||
? serverProp.SafeGetString() ?? ""
|
||||
: "";
|
||||
|
||||
var clients = _getClients()
|
||||
|
||||
@@ -57,7 +57,7 @@ public class McpTool : IAgentTool
|
||||
{
|
||||
arguments[prop.Name] = prop.Value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => prop.Value.GetString()!,
|
||||
JsonValueKind.String => prop.Value.SafeGetString()!,
|
||||
JsonValueKind.Number => prop.Value.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
|
||||
@@ -51,9 +51,9 @@ public class MemoryTool : IAgentTool
|
||||
|
||||
memoryService.Load(context.WorkFolder);
|
||||
|
||||
if (!args.TryGetProperty("action", out var actionEl))
|
||||
if (!args.SafeTryGetProperty("action", out var actionEl))
|
||||
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
|
||||
var action = actionEl.GetString() ?? "";
|
||||
var action = actionEl.SafeGetString() ?? "";
|
||||
|
||||
return Task.FromResult(action switch
|
||||
{
|
||||
@@ -70,8 +70,8 @@ public class MemoryTool : IAgentTool
|
||||
|
||||
private static ToolResult ExecuteSave(JsonElement args, AgentMemoryService svc, AgentContext context)
|
||||
{
|
||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() ?? "fact" : "fact";
|
||||
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
var type = args.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "fact" : "fact";
|
||||
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return ToolResult.Fail("content가 필요합니다.");
|
||||
@@ -87,7 +87,7 @@ public class MemoryTool : IAgentTool
|
||||
|
||||
private static ToolResult ExecuteSearch(JsonElement args, AgentMemoryService svc)
|
||||
{
|
||||
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
|
||||
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return ToolResult.Fail("query가 필요합니다.");
|
||||
|
||||
@@ -150,7 +150,7 @@ public class MemoryTool : IAgentTool
|
||||
|
||||
private static ToolResult ExecuteDelete(JsonElement args, AgentMemoryService svc)
|
||||
{
|
||||
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
|
||||
var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
return ToolResult.Fail("id가 필요합니다.");
|
||||
|
||||
@@ -161,8 +161,8 @@ public class MemoryTool : IAgentTool
|
||||
|
||||
private static ToolResult ExecuteSaveScope(JsonElement args, AgentMemoryService svc, AgentContext context)
|
||||
{
|
||||
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
|
||||
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
var scope = args.SafeTryGetProperty("scope", out var s) ? s.SafeGetString() ?? "" : "";
|
||||
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
@@ -176,8 +176,8 @@ public class MemoryTool : IAgentTool
|
||||
|
||||
private static ToolResult ExecuteDeleteScope(JsonElement args, AgentMemoryService svc, AgentContext context)
|
||||
{
|
||||
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
|
||||
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
|
||||
var scope = args.SafeTryGetProperty("scope", out var s) ? s.SafeGetString() ?? "" : "";
|
||||
var query = args.SafeTryGetProperty("query", out var q) ? q.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
@@ -191,7 +191,7 @@ public class MemoryTool : IAgentTool
|
||||
|
||||
private static ToolResult ExecuteShowScope(JsonElement args, AgentMemoryService svc, AgentContext context)
|
||||
{
|
||||
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
|
||||
var scope = args.SafeTryGetProperty("scope", out var s) ? s.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace AxCopilot.Services.Agent;
|
||||
/// <summary>여러 파일을 한 번에 읽어 결합 반환하는 도구 (최대 20개).</summary>
|
||||
public class MultiReadTool : IAgentTool
|
||||
{
|
||||
private const int MaxFiles = 20;
|
||||
private const int MaxFiles = 8;
|
||||
private const int DefaultMaxLines = 300;
|
||||
private const int HardMaxLines = 2000;
|
||||
|
||||
@@ -51,7 +51,7 @@ public class MultiReadTool : IAgentTool
|
||||
{
|
||||
// --- Parse parameters ---
|
||||
var maxLines = DefaultMaxLines;
|
||||
if (args.TryGetProperty("max_lines", out var mlEl))
|
||||
if (args.SafeTryGetProperty("max_lines", out var mlEl))
|
||||
{
|
||||
maxLines = mlEl.GetInt32();
|
||||
if (maxLines <= 0) maxLines = DefaultMaxLines;
|
||||
@@ -60,23 +60,23 @@ public class MultiReadTool : IAgentTool
|
||||
|
||||
// offset is 1-based; convert to 0-based skip count
|
||||
var offsetParam = 1;
|
||||
if (args.TryGetProperty("offset", out var offEl))
|
||||
if (args.SafeTryGetProperty("offset", out var offEl))
|
||||
{
|
||||
offsetParam = offEl.GetInt32();
|
||||
if (offsetParam < 1) offsetParam = 1;
|
||||
}
|
||||
var skipLines = offsetParam - 1; // number of lines to skip (0 = start from line 1)
|
||||
|
||||
var showEncoding = args.TryGetProperty("show_encoding", out var seEl) && seEl.GetBoolean();
|
||||
var showEncoding = args.SafeTryGetProperty("show_encoding", out var seEl) && seEl.GetBoolean();
|
||||
|
||||
// --- Validate paths array ---
|
||||
if (!args.TryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty("paths", out var pathsEl) || pathsEl.ValueKind != JsonValueKind.Array)
|
||||
return Task.FromResult(ToolResult.Fail("'paths'는 문자열 배열이어야 합니다."));
|
||||
|
||||
var rawPaths = new List<string>();
|
||||
foreach (var p in pathsEl.EnumerateArray())
|
||||
{
|
||||
var s = p.GetString();
|
||||
var s = p.SafeGetString();
|
||||
if (!string.IsNullOrEmpty(s)) rawPaths.Add(s);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,9 +45,9 @@ public class NotifyTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var title = args.GetProperty("title").GetString() ?? "알림";
|
||||
var message = args.GetProperty("message").GetString() ?? "";
|
||||
var level = args.TryGetProperty("level", out var lv) ? lv.GetString() ?? "info" : "info";
|
||||
var title = args.GetProperty("title").SafeGetString() ?? "알림";
|
||||
var message = args.GetProperty("message").SafeGetString() ?? "";
|
||||
var level = args.SafeTryGetProperty("level", out var lv) ? lv.SafeGetString() ?? "info" : "info";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -140,14 +140,13 @@ public class NotifyTool : IAgentTool
|
||||
Grid.SetColumnSpan(toast, grid.ColumnDefinitions.Count > 0 ? grid.ColumnDefinitions.Count : 1);
|
||||
grid.Children.Add(toast);
|
||||
|
||||
// 5초 후 자동 제거 (페이드 아웃)
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
||||
timer.Tick += (_, _) =>
|
||||
// 5초 후 자동 제거 — DispatcherTimer 대신 애니메이션 Completed 사용하여 타이머 누적 방지
|
||||
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(300))
|
||||
{
|
||||
timer.Stop();
|
||||
grid.Children.Remove(toast);
|
||||
BeginTime = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
timer.Start();
|
||||
fadeOut.Completed += (_, _) => grid.Children.Remove(toast);
|
||||
toast.BeginAnimation(System.Windows.UIElement.OpacityProperty, fadeOut);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ public class OpenExternalTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var rawPath = args.GetProperty("path").GetString() ?? "";
|
||||
var rawPath = args.GetProperty("path").SafeGetString() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(rawPath))
|
||||
return Task.FromResult(ToolResult.Fail("경로가 비어 있습니다."));
|
||||
|
||||
|
||||
@@ -63,9 +63,9 @@ public class PlaybookTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("action", out var actionEl))
|
||||
if (!args.SafeTryGetProperty("action", out var actionEl))
|
||||
return ToolResult.Fail("action이 필요합니다.");
|
||||
var action = actionEl.GetString() ?? "";
|
||||
var action = actionEl.SafeGetString() ?? "";
|
||||
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되지 않았습니다.");
|
||||
@@ -84,8 +84,8 @@ public class PlaybookTool : IAgentTool
|
||||
|
||||
private static async Task<ToolResult> SavePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
|
||||
{
|
||||
var name = args.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
var description = args.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "";
|
||||
var name = args.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
|
||||
var description = args.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return ToolResult.Fail("플레이북 name이 필요합니다.");
|
||||
@@ -94,11 +94,11 @@ public class PlaybookTool : IAgentTool
|
||||
|
||||
// steps 파싱
|
||||
var steps = new List<string>();
|
||||
if (args.TryGetProperty("steps", out var stepsEl) && stepsEl.ValueKind == JsonValueKind.Array)
|
||||
if (args.SafeTryGetProperty("steps", out var stepsEl) && stepsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var step in stepsEl.EnumerateArray())
|
||||
{
|
||||
var s = step.GetString();
|
||||
var s = step.SafeGetString();
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
steps.Add(s);
|
||||
}
|
||||
@@ -109,11 +109,11 @@ public class PlaybookTool : IAgentTool
|
||||
|
||||
// tools_used 파싱
|
||||
var toolsUsed = new List<string>();
|
||||
if (args.TryGetProperty("tools_used", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array)
|
||||
if (args.SafeTryGetProperty("tools_used", out var toolsEl) && toolsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var tool in toolsEl.EnumerateArray())
|
||||
{
|
||||
var t = tool.GetString();
|
||||
var t = tool.SafeGetString();
|
||||
if (!string.IsNullOrWhiteSpace(t))
|
||||
toolsUsed.Add(t);
|
||||
}
|
||||
@@ -192,10 +192,10 @@ public class PlaybookTool : IAgentTool
|
||||
|
||||
private static async Task<ToolResult> DescribePlaybook(JsonElement args, string playbookDir, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
if (!args.SafeTryGetProperty("id", out var idEl))
|
||||
return ToolResult.Fail("플레이북 id가 필요합니다.");
|
||||
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
|
||||
var playbook = await FindPlaybookById(playbookDir, id, ct);
|
||||
|
||||
if (playbook == null)
|
||||
@@ -222,10 +222,10 @@ public class PlaybookTool : IAgentTool
|
||||
|
||||
private static ToolResult DeletePlaybook(JsonElement args, string playbookDir)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
if (!args.SafeTryGetProperty("id", out var idEl))
|
||||
return ToolResult.Fail("삭제할 플레이북 id가 필요합니다.");
|
||||
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.GetString(), out var parsed) ? parsed : -1;
|
||||
var id = idEl.ValueKind == JsonValueKind.Number ? idEl.GetInt32() : int.TryParse(idEl.SafeGetString(), out var parsed) ? parsed : -1;
|
||||
|
||||
if (!Directory.Exists(playbookDir))
|
||||
return ToolResult.Fail("저장된 플레이북이 없습니다.");
|
||||
|
||||
@@ -298,12 +298,12 @@ public class PptxSkill : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var presTitle = args.TryGetProperty("title", out var tt) ? tt.GetString() ?? "Presentation" : "Presentation";
|
||||
var presTitle = args.SafeTryGetProperty("title", out var tt) ? tt.SafeGetString() ?? "Presentation" : "Presentation";
|
||||
string path;
|
||||
if (args.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.GetString()))
|
||||
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
|
||||
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
|
||||
{
|
||||
path = pathEl.GetString()!;
|
||||
path = pathEl.SafeGetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -311,10 +311,10 @@ public class PptxSkill : IAgentTool
|
||||
if (safe.Length > 60) safe = safe[..60].TrimEnd();
|
||||
path = (string.IsNullOrWhiteSpace(safe) ? "presentation" : safe) + ".pptx";
|
||||
}
|
||||
var theme = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
|
||||
var aspect = args.TryGetProperty("aspect", out var asp) ? asp.GetString() ?? "widescreen" : "widescreen";
|
||||
var theme = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "professional" : "professional";
|
||||
var aspect = args.SafeTryGetProperty("aspect", out var asp) ? asp.SafeGetString() ?? "widescreen" : "widescreen";
|
||||
|
||||
if (!args.TryGetProperty("slides", out var slidesEl) || slidesEl.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty("slides", out var slidesEl) || slidesEl.ValueKind != JsonValueKind.Array)
|
||||
return ToolResult.Fail("slides 배열이 필요합니다.");
|
||||
|
||||
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
|
||||
@@ -333,10 +333,10 @@ public class PptxSkill : IAgentTool
|
||||
// ── 테마 결정 우선순위: theme_file > custom_colors > theme 이름 ──────
|
||||
FullTheme fullTheme;
|
||||
|
||||
if (args.TryGetProperty("theme_file", out var tfEl) && !string.IsNullOrEmpty(tfEl.GetString()))
|
||||
if (args.SafeTryGetProperty("theme_file", out var tfEl) && !string.IsNullOrEmpty(tfEl.SafeGetString()))
|
||||
{
|
||||
// 기존 PPTX에서 테마 색상 추출 → default layout (professional)
|
||||
var tfPath = FileReadTool.ResolvePath(tfEl.GetString()!, context.WorkFolder);
|
||||
var tfPath = FileReadTool.ResolvePath(tfEl.SafeGetString()!, context.WorkFolder);
|
||||
var extracted = ExtractThemeFromPptx(tfPath);
|
||||
var baseLayout = FullThemes["professional"].Layout;
|
||||
fullTheme = extracted != null
|
||||
@@ -344,12 +344,12 @@ public class PptxSkill : IAgentTool
|
||||
: FullThemes["professional"];
|
||||
}
|
||||
else if (string.Equals(theme, "custom", StringComparison.OrdinalIgnoreCase)
|
||||
&& args.TryGetProperty("custom_colors", out var ccEl)
|
||||
&& args.SafeTryGetProperty("custom_colors", out var ccEl)
|
||||
&& ccEl.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
// 사용자 지정 색상 → default layout (professional)
|
||||
static string Hex(JsonElement obj, string key, string fallback) =>
|
||||
obj.TryGetProperty(key, out var v) ? (v.GetString() ?? fallback).TrimStart('#') : fallback;
|
||||
obj.SafeTryGetProperty(key, out var v) ? (v.SafeGetString() ?? fallback).TrimStart('#') : fallback;
|
||||
var customColors = new ThemeColors(
|
||||
Primary: Hex(ccEl, "primary", "1F4E79"),
|
||||
Accent: Hex(ccEl, "accent", "2E75B6"),
|
||||
@@ -441,7 +441,7 @@ public class PptxSkill : IAgentTool
|
||||
|
||||
foreach (var slideEl in slidesEl.EnumerateArray())
|
||||
{
|
||||
var layout = slideEl.TryGetProperty("layout", out var lay) ? lay.GetString() ?? "content" : "content";
|
||||
var layout = slideEl.SafeTryGetProperty("layout", out var lay) ? lay.SafeGetString() ?? "content" : "content";
|
||||
var slidePart = presPart.AddNewPart<SlidePart>();
|
||||
slidePart.AddPart(layoutPart);
|
||||
|
||||
@@ -480,11 +480,11 @@ public class PptxSkill : IAgentTool
|
||||
}
|
||||
|
||||
// 발표자 노트
|
||||
if (slideEl.TryGetProperty("notes", out var notesEl) &&
|
||||
if (slideEl.SafeTryGetProperty("notes", out var notesEl) &&
|
||||
notesEl.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(notesEl.GetString()))
|
||||
!string.IsNullOrWhiteSpace(notesEl.SafeGetString()))
|
||||
{
|
||||
AddNotesSlide(slidePart, notesEl.GetString()!);
|
||||
AddNotesSlide(slidePart, notesEl.SafeGetString()!);
|
||||
}
|
||||
|
||||
slidePart.Slide.Save();
|
||||
@@ -928,11 +928,11 @@ public class PptxSkill : IAgentTool
|
||||
private static void BuildTableSlide(ShapeTree t, JsonElement s, ThemeColors c, long W, long H, ref uint id)
|
||||
{
|
||||
var title = Str(s, "title");
|
||||
var headers = s.TryGetProperty("headers", out var hEl)
|
||||
? hEl.EnumerateArray().Select(x => x.GetString() ?? "").ToList()
|
||||
var headers = s.SafeTryGetProperty("headers", out var hEl)
|
||||
? hEl.EnumerateArray().Select(x => x.SafeGetString() ?? "").ToList()
|
||||
: new List<string>();
|
||||
var rows = s.TryGetProperty("rows", out var rEl)
|
||||
? rEl.EnumerateArray().Select(r => r.EnumerateArray().Select(c2 => c2.GetString() ?? "").ToList()).ToList()
|
||||
var rows = s.SafeTryGetProperty("rows", out var rEl)
|
||||
? rEl.EnumerateArray().Select(r => r.EnumerateArray().Select(c2 => c2.SafeGetString() ?? "").ToList()).ToList()
|
||||
: new List<List<string>>();
|
||||
const long M = 450000;
|
||||
|
||||
@@ -1233,8 +1233,8 @@ public class PptxSkill : IAgentTool
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
private static string Str(JsonElement e, string key)
|
||||
=> e.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() ?? ""
|
||||
=> e.SafeTryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.SafeGetString() ?? ""
|
||||
: "";
|
||||
|
||||
private static void AddNotesSlide(SlidePart slidePart, string notes)
|
||||
|
||||
187
src/AxCopilot/Services/Agent/PptxToHtmlConverter.cs
Normal file
187
src/AxCopilot/Services/Agent/PptxToHtmlConverter.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Presentation;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>PPTX 파일을 HTML 슬라이드 카드로 변환하여 WebView2에서 미리보기합니다.</summary>
|
||||
internal static class PptxToHtmlConverter
|
||||
{
|
||||
public static string Convert(string pptxPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = PresentationDocument.Open(pptxPath, false);
|
||||
var presentationPart = doc.PresentationPart;
|
||||
if (presentationPart?.Presentation?.SlideIdList == null)
|
||||
return WrapHtml("<p style='color:#888'>프레젠테이션 내용이 없습니다.</p>");
|
||||
|
||||
var slideIds = presentationPart.Presentation.SlideIdList.Elements<SlideId>().ToList();
|
||||
if (slideIds.Count == 0)
|
||||
return WrapHtml("<p style='color:#888'>슬라이드가 없습니다.</p>");
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"<div class='slide-info'>{slideIds.Count}개 슬라이드</div>");
|
||||
|
||||
for (int i = 0; i < slideIds.Count; i++)
|
||||
{
|
||||
var slideId = slideIds[i];
|
||||
var relId = slideId.RelationshipId?.Value;
|
||||
if (string.IsNullOrEmpty(relId)) continue;
|
||||
|
||||
SlidePart slidePart;
|
||||
try { slidePart = (SlidePart)presentationPart.GetPartById(relId); }
|
||||
catch { continue; }
|
||||
|
||||
sb.AppendLine(RenderSlide(slidePart, i + 1, doc));
|
||||
}
|
||||
|
||||
return WrapHtml(sb.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return WrapHtml($"<p style='color:#e53e3e'>PPTX 파싱 오류: {Escape(ex.Message)}</p>");
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderSlide(SlidePart slidePart, int slideNumber, PresentationDocument doc)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"<div class='slide-card'>");
|
||||
sb.AppendLine($"<div class='slide-number'>슬라이드 {slideNumber}</div>");
|
||||
|
||||
var slide = slidePart.Slide;
|
||||
if (slide?.CommonSlideData?.ShapeTree == null)
|
||||
{
|
||||
sb.AppendLine("<p class='empty'>내용 없음</p>");
|
||||
sb.AppendLine("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
var shapes = slide.CommonSlideData.ShapeTree.Elements<Shape>().ToList();
|
||||
var titleRendered = false;
|
||||
|
||||
foreach (var shape in shapes)
|
||||
{
|
||||
var textBody = shape.TextBody;
|
||||
if (textBody == null) continue;
|
||||
|
||||
var paragraphs = textBody.Elements<DocumentFormat.OpenXml.Drawing.Paragraph>().ToList();
|
||||
if (paragraphs.Count == 0) continue;
|
||||
|
||||
// 제목 판별: 첫 번째 shape이고 placeholder가 title 유형인 경우
|
||||
var isTitle = !titleRendered && IsPlaceholderTitle(shape);
|
||||
|
||||
foreach (var para in paragraphs)
|
||||
{
|
||||
var text = ExtractParagraphText(para);
|
||||
if (string.IsNullOrWhiteSpace(text)) continue;
|
||||
|
||||
if (isTitle)
|
||||
{
|
||||
sb.AppendLine($"<h2 class='slide-title'>{Escape(text)}</h2>");
|
||||
titleRendered = true;
|
||||
isTitle = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($"<p>{Escape(text)}</p>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 이미지 추출
|
||||
var pictures = slide.CommonSlideData.ShapeTree.Elements<Picture>().ToList();
|
||||
foreach (var pic in pictures)
|
||||
{
|
||||
var img = ExtractImage(pic, slidePart);
|
||||
if (img != null)
|
||||
sb.AppendLine(img);
|
||||
}
|
||||
|
||||
sb.AppendLine("</div>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static bool IsPlaceholderTitle(Shape shape)
|
||||
{
|
||||
var nvSpPr = shape.NonVisualShapeProperties;
|
||||
var ph = nvSpPr?.ApplicationNonVisualDrawingProperties?
|
||||
.GetFirstChild<PlaceholderShape>();
|
||||
if (ph == null) return false;
|
||||
|
||||
var phType = ph.Type?.Value;
|
||||
return phType == PlaceholderValues.Title
|
||||
|| phType == PlaceholderValues.CenteredTitle;
|
||||
}
|
||||
|
||||
private static string ExtractParagraphText(DocumentFormat.OpenXml.Drawing.Paragraph para)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var run in para.Elements<DocumentFormat.OpenXml.Drawing.Run>())
|
||||
{
|
||||
var text = run.Text?.Text;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
sb.Append(text);
|
||||
}
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
private static string? ExtractImage(Picture pic, SlidePart slidePart)
|
||||
{
|
||||
try
|
||||
{
|
||||
var blipFill = pic.BlipFill;
|
||||
var blip = blipFill?.Blip;
|
||||
if (blip?.Embed?.Value == null) return null;
|
||||
|
||||
var part = slidePart.GetPartById(blip.Embed.Value);
|
||||
if (part == null) return null;
|
||||
|
||||
using var stream = part.GetStream();
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
var base64 = System.Convert.ToBase64String(ms.ToArray());
|
||||
var mime = part.ContentType ?? "image/png";
|
||||
return $"<img src='data:{mime};base64,{base64}' class='slide-img' />";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Escape(string text)
|
||||
=> System.Net.WebUtility.HtmlEncode(text);
|
||||
|
||||
private static string WrapHtml(string body)
|
||||
{
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang='ko'>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #f3f4f6; color: #1a1a1a;
|
||||
line-height: 1.6; padding: 20px; font-size: 13px; }}
|
||||
.slide-info {{ color: #6b7280; font-size: 12px; margin-bottom: 16px; text-align: center; }}
|
||||
.slide-card {{ background: #fff; border-radius: 12px; padding: 28px 32px; margin-bottom: 16px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08); min-height: 120px;
|
||||
border: 1px solid #e5e7eb; }}
|
||||
.slide-number {{ color: #9ca3af; font-size: 10px; font-weight: 600; margin-bottom: 8px;
|
||||
text-transform: uppercase; letter-spacing: 1px; }}
|
||||
.slide-title {{ font-size: 18px; font-weight: 700; color: #111; margin-bottom: 10px; }}
|
||||
.slide-card p {{ margin: 4px 0; color: #374151; }}
|
||||
.slide-img {{ max-width: 100%; height: auto; margin: 10px 0; border-radius: 6px; }}
|
||||
.empty {{ color: #d1d5db; font-style: italic; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{body}
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,11 @@ public class ProcessTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
if (!args.TryGetProperty("command", out var cmdEl))
|
||||
if (!args.SafeTryGetProperty("command", out var cmdEl))
|
||||
return ToolResult.Fail("command가 필요합니다.");
|
||||
var command = cmdEl.GetString() ?? "";
|
||||
var shell = args.TryGetProperty("shell", out var sh) ? sh.GetString() ?? "cmd" : "cmd";
|
||||
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
|
||||
var command = cmdEl.SafeGetString() ?? "";
|
||||
var shell = args.SafeTryGetProperty("shell", out var sh) ? sh.SafeGetString() ?? "cmd" : "cmd";
|
||||
var timeout = args.SafeTryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 120) : 30;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return ToolResult.Fail("명령이 비어 있습니다.");
|
||||
|
||||
@@ -53,10 +53,10 @@ public class ProjectRuleTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
var section = args.TryGetProperty("section", out var s) ? s.GetString() ?? "" : "";
|
||||
var ruleName = args.TryGetProperty("rule_name", out var rn) ? rn.GetString() ?? "" : "";
|
||||
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
|
||||
var content = args.SafeTryGetProperty("content", out var c) ? c.SafeGetString() ?? "" : "";
|
||||
var section = args.SafeTryGetProperty("section", out var s) ? s.SafeGetString() ?? "" : "";
|
||||
var ruleName = args.SafeTryGetProperty("rule_name", out var rn) ? rn.SafeGetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrEmpty(context.WorkFolder))
|
||||
return ToolResult.Fail("작업 폴더가 설정되어 있지 않습니다.");
|
||||
|
||||
@@ -56,11 +56,11 @@ public class RegexTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var pattern = args.GetProperty("pattern").GetString() ?? "";
|
||||
var text = args.GetProperty("text").GetString() ?? "";
|
||||
var replacement = args.TryGetProperty("replacement", out var r) ? r.GetString() ?? "" : "";
|
||||
var flags = args.TryGetProperty("flags", out var f) ? f.GetString() ?? "" : "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
var pattern = args.GetProperty("pattern").SafeGetString() ?? "";
|
||||
var text = args.GetProperty("text").SafeGetString() ?? "";
|
||||
var replacement = args.SafeTryGetProperty("replacement", out var r) ? r.SafeGetString() ?? "" : "";
|
||||
var flags = args.SafeTryGetProperty("flags", out var f) ? f.SafeGetString() ?? "" : "";
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -42,8 +42,8 @@ public class SkillManagerTool : IAgentTool
|
||||
if (!(app?.SettingsService?.Settings.Llm.EnableSkillSystem ?? true))
|
||||
return ToolResult.Ok("스킬 시스템이 비활성 상태입니다. 설정 → AX Agent → 공통에서 활성화하세요.");
|
||||
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
var skillName = args.TryGetProperty("skill_name", out var s) ? s.GetString() ?? "" : "";
|
||||
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
|
||||
var skillName = args.SafeTryGetProperty("skill_name", out var s) ? s.SafeGetString() ?? "" : "";
|
||||
|
||||
return action switch
|
||||
{
|
||||
|
||||
@@ -59,9 +59,9 @@ public class SnippetRunnerTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var language = args.GetProperty("language").GetString() ?? "";
|
||||
var code = args.TryGetProperty("code", out var c) ? c.GetString() ?? "" : "";
|
||||
var timeout = args.TryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
|
||||
var language = args.GetProperty("language").SafeGetString() ?? "";
|
||||
var code = args.SafeTryGetProperty("code", out var c) ? c.SafeGetString() ?? "" : "";
|
||||
var timeout = args.SafeTryGetProperty("timeout", out var to) ? Math.Min(to.GetInt32(), 60) : 30;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return ToolResult.Fail("code가 비어 있습니다.");
|
||||
|
||||
@@ -50,8 +50,8 @@ public class SqlTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var dbPath = args.GetProperty("db_path").GetString() ?? "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
var dbPath = args.GetProperty("db_path").SafeGetString() ?? "";
|
||||
|
||||
if (!Path.IsPathRooted(dbPath))
|
||||
dbPath = Path.Combine(context.WorkFolder, dbPath);
|
||||
@@ -86,10 +86,10 @@ public class SqlTool : IAgentTool
|
||||
|
||||
private static ToolResult QueryAction(SqliteConnection conn, JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("sql", out var sqlProp))
|
||||
if (!args.SafeTryGetProperty("sql", out var sqlProp))
|
||||
return ToolResult.Fail("'sql' parameter is required for query action");
|
||||
|
||||
var sql = sqlProp.GetString() ?? "";
|
||||
var sql = sqlProp.SafeGetString() ?? "";
|
||||
|
||||
// SELECT만 허용
|
||||
if (!sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) &&
|
||||
@@ -97,8 +97,8 @@ public class SqlTool : IAgentTool
|
||||
!sql.TrimStart().StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase))
|
||||
return ToolResult.Fail("Query action only allows SELECT/WITH/PRAGMA statements. Use 'execute' for modifications.");
|
||||
|
||||
var maxRows = args.TryGetProperty("max_rows", out var mr) && int.TryParse(mr.GetString(), out var mrv)
|
||||
? Math.Min(mrv, 1000) : 100;
|
||||
var maxRows = args.SafeTryGetProperty("max_rows", out var mr)
|
||||
? Math.Min(mr.SafeGetInt32(100), 1000) : 100;
|
||||
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
@@ -135,10 +135,10 @@ public class SqlTool : IAgentTool
|
||||
|
||||
private static ToolResult ExecuteAction(SqliteConnection conn, JsonElement args)
|
||||
{
|
||||
if (!args.TryGetProperty("sql", out var sqlProp))
|
||||
if (!args.SafeTryGetProperty("sql", out var sqlProp))
|
||||
return ToolResult.Fail("'sql' parameter is required for execute action");
|
||||
|
||||
var sql = sqlProp.GetString() ?? "";
|
||||
var sql = sqlProp.SafeGetString() ?? "";
|
||||
|
||||
// DDL/DML만 허용 (DROP DATABASE 등 위험 명령 차단)
|
||||
var trimmed = sql.TrimStart().ToUpperInvariant();
|
||||
|
||||
@@ -44,8 +44,8 @@ public class SubAgentTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var task = args.TryGetProperty("task", out var t) ? t.GetString() ?? "" : "";
|
||||
var id = args.TryGetProperty("id", out var i) ? i.GetString() ?? "" : "";
|
||||
var task = args.SafeTryGetProperty("task", out var t) ? t.SafeGetString() ?? "" : "";
|
||||
var id = args.SafeTryGetProperty("id", out var i) ? i.SafeGetString() ?? "" : "";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(task) || string.IsNullOrWhiteSpace(id))
|
||||
return Task.FromResult(ToolResult.Fail("task and id are required."));
|
||||
@@ -72,11 +72,15 @@ public class SubAgentTool : IAgentTool
|
||||
StartedAt = DateTime.Now,
|
||||
};
|
||||
|
||||
// P2: 부모 취소 토큰 연동 — 부모 에이전트 중지 시 자식도 즉시 취소
|
||||
var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
subTask.Cts = cts;
|
||||
|
||||
subTask.RunTask = System.Threading.Tasks.Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await RunSubAgentAsync(id, task, context).ConfigureAwait(false);
|
||||
var result = await RunSubAgentAsync(id, task, context, cts.Token).ConfigureAwait(false);
|
||||
subTask.Result = result;
|
||||
subTask.Success = true;
|
||||
NotifyStatus(new SubAgentStatusEvent
|
||||
@@ -89,6 +93,20 @@ public class SubAgentTool : IAgentTool
|
||||
Timestamp = DateTime.Now,
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
subTask.Result = "Cancelled: parent agent was stopped.";
|
||||
subTask.Success = false;
|
||||
NotifyStatus(new SubAgentStatusEvent
|
||||
{
|
||||
Id = id,
|
||||
Task = task,
|
||||
Status = SubAgentRunStatus.Failed,
|
||||
Summary = $"Sub-agent '{id}' cancelled.",
|
||||
Result = subTask.Result,
|
||||
Timestamp = DateTime.Now,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
subTask.Result = $"Error: {ex.Message}";
|
||||
@@ -106,8 +124,9 @@ public class SubAgentTool : IAgentTool
|
||||
finally
|
||||
{
|
||||
subTask.CompletedAt = DateTime.Now;
|
||||
cts.Dispose();
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
}, cts.Token);
|
||||
|
||||
lock (_lock)
|
||||
_activeTasks[id] = subTask;
|
||||
@@ -125,7 +144,7 @@ public class SubAgentTool : IAgentTool
|
||||
$"Sub-agent '{id}' started.\nTask: {task}\nUse wait_agents later to collect the result."));
|
||||
}
|
||||
|
||||
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext)
|
||||
private static async Task<string> RunSubAgentAsync(string id, string task, AgentContext parentContext, CancellationToken ct)
|
||||
{
|
||||
var settings = CreateSubAgentSettings(parentContext);
|
||||
using var llm = new LlmService(settings);
|
||||
@@ -150,7 +169,7 @@ public class SubAgentTool : IAgentTool
|
||||
}
|
||||
};
|
||||
|
||||
var finalText = await loop.RunAsync(messages, CancellationToken.None).ConfigureAwait(false);
|
||||
var finalText = await loop.RunAsync(messages, ct).ConfigureAwait(false);
|
||||
|
||||
var eventSummary = SummarizeEvents(loop.Events);
|
||||
var sb = new StringBuilder();
|
||||
@@ -451,16 +470,16 @@ public class WaitAgentsTool : IAgentTool
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
List<string>? ids = null;
|
||||
if (args.TryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
|
||||
if (args.SafeTryGetProperty("ids", out var idsEl) && idsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
ids = idsEl.EnumerateArray()
|
||||
.Where(x => x.ValueKind == JsonValueKind.String)
|
||||
.Select(x => x.GetString() ?? "")
|
||||
.Select(x => x.SafeGetString() ?? "")
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var completedOnly = args.TryGetProperty("completed_only", out var completedEl) &&
|
||||
var completedOnly = args.SafeTryGetProperty("completed_only", out var completedEl) &&
|
||||
completedEl.ValueKind == JsonValueKind.True;
|
||||
|
||||
var result = await SubAgentTool.WaitAsync(ids, completedOnly, ct).ConfigureAwait(false);
|
||||
@@ -477,6 +496,7 @@ public class SubAgentTask
|
||||
public bool Success { get; set; }
|
||||
public string? Result { get; set; }
|
||||
public Task? RunTask { get; set; }
|
||||
public CancellationTokenSource? Cts { get; set; }
|
||||
}
|
||||
|
||||
public enum SubAgentRunStatus
|
||||
|
||||
@@ -40,16 +40,16 @@ public class SuggestActionsTool : IAgentTool
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!args.TryGetProperty("actions", out var actionsEl) || actionsEl.ValueKind != JsonValueKind.Array)
|
||||
if (!args.SafeTryGetProperty("actions", out var actionsEl) || actionsEl.ValueKind != JsonValueKind.Array)
|
||||
return Task.FromResult(ToolResult.Fail("actions 배열이 필요합니다."));
|
||||
|
||||
var actions = new List<Dictionary<string, string>>();
|
||||
foreach (var item in actionsEl.EnumerateArray())
|
||||
{
|
||||
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
|
||||
var command = item.TryGetProperty("command", out var c) ? c.GetString() ?? "" : "";
|
||||
var icon = item.TryGetProperty("icon", out var i) ? i.GetString() ?? "" : "";
|
||||
var priority = item.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
|
||||
var label = item.SafeTryGetProperty("label", out var l) ? l.SafeGetString() ?? "" : "";
|
||||
var command = item.SafeTryGetProperty("command", out var c) ? c.SafeGetString() ?? "" : "";
|
||||
var icon = item.SafeTryGetProperty("icon", out var i) ? i.SafeGetString() ?? "" : "";
|
||||
var priority = item.SafeTryGetProperty("priority", out var p) ? p.SafeGetString() ?? "medium" : "medium";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
return Task.FromResult(ToolResult.Fail("각 action에는 label이 필요합니다."));
|
||||
@@ -76,7 +76,7 @@ public class SuggestActionsTool : IAgentTool
|
||||
if (actions.Count < 1 || actions.Count > 5)
|
||||
return Task.FromResult(ToolResult.Fail("actions는 1~5개 사이여야 합니다."));
|
||||
|
||||
var contextSummary = args.TryGetProperty("context", out var ctx) ? ctx.GetString() ?? "" : "";
|
||||
var contextSummary = args.SafeTryGetProperty("context", out var ctx) ? ctx.SafeGetString() ?? "" : "";
|
||||
|
||||
// 구조화된 JSON 응답 생성
|
||||
var result = new Dictionary<string, object>
|
||||
|
||||
@@ -23,14 +23,14 @@ public sealed class TaskCreateTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var title = args.TryGetProperty("title", out var titleEl) ? (titleEl.GetString() ?? "").Trim() : "";
|
||||
var title = args.SafeTryGetProperty("title", out var titleEl) ? (titleEl.SafeGetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return Task.FromResult(ToolResult.Fail("title is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var description = args.TryGetProperty("description", out var descEl) ? (descEl.GetString() ?? "").Trim() : "";
|
||||
var priority = args.TryGetProperty("priority", out var priEl) ? (priEl.GetString() ?? "medium").Trim().ToLowerInvariant() : "medium";
|
||||
var description = args.SafeTryGetProperty("description", out var descEl) ? (descEl.SafeGetString() ?? "").Trim() : "";
|
||||
var priority = args.SafeTryGetProperty("priority", out var priEl) ? (priEl.SafeGetString() ?? "medium").Trim().ToLowerInvariant() : "medium";
|
||||
if (!TaskBoardStore.IsValidPriority(priority))
|
||||
priority = "medium";
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class TaskGetTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
if (!args.SafeTryGetProperty("id", out var idEl))
|
||||
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed class TaskListTool : IAgentTool
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var status = args.TryGetProperty("status", out var statusEl) ? (statusEl.GetString() ?? "").Trim().ToLowerInvariant() : "";
|
||||
var status = args.SafeTryGetProperty("status", out var statusEl) ? (statusEl.SafeGetString() ?? "").Trim().ToLowerInvariant() : "";
|
||||
var tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
tasks = tasks.Where(t => string.Equals(t.Status, status, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
@@ -24,19 +24,19 @@ public sealed class TaskOutputTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
if (!args.SafeTryGetProperty("id", out var idEl))
|
||||
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||
if (!args.TryGetProperty("output", out var outEl))
|
||||
if (!args.SafeTryGetProperty("output", out var outEl))
|
||||
return Task.FromResult(ToolResult.Fail("output is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var id = idEl.GetInt32();
|
||||
var output = (outEl.GetString() ?? "").Trim();
|
||||
var output = (outEl.SafeGetString() ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(output))
|
||||
return Task.FromResult(ToolResult.Fail("output is empty."));
|
||||
|
||||
var append = !args.TryGetProperty("append", out var appendEl) || appendEl.ValueKind != JsonValueKind.False;
|
||||
var append = !args.SafeTryGetProperty("append", out var appendEl) || appendEl.ValueKind != JsonValueKind.False;
|
||||
var tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||
var task = tasks.FirstOrDefault(t => t.Id == id);
|
||||
if (task == null)
|
||||
|
||||
@@ -22,13 +22,13 @@ public sealed class TaskStopTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
if (!args.SafeTryGetProperty("id", out var idEl))
|
||||
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var id = idEl.GetInt32();
|
||||
var reason = args.TryGetProperty("reason", out var reasonEl) ? (reasonEl.GetString() ?? "").Trim() : "";
|
||||
var reason = args.SafeTryGetProperty("reason", out var reasonEl) ? (reasonEl.SafeGetString() ?? "").Trim() : "";
|
||||
var tasks = TaskBoardStore.Load(context.WorkFolder);
|
||||
var task = tasks.FirstOrDefault(t => t.Id == id);
|
||||
if (task == null)
|
||||
|
||||
@@ -54,7 +54,7 @@ public class TaskTrackerTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
@@ -75,8 +75,8 @@ public class TaskTrackerTool : IAgentTool
|
||||
|
||||
private static ToolResult ScanTodos(JsonElement args, AgentContext context)
|
||||
{
|
||||
var extStr = args.TryGetProperty("extensions", out var e)
|
||||
? e.GetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c"
|
||||
var extStr = args.SafeTryGetProperty("extensions", out var e)
|
||||
? e.SafeGetString() ?? ".cs,.py,.js,.ts,.java,.cpp,.c"
|
||||
: ".cs,.py,.js,.ts,.java,.cpp,.c";
|
||||
var exts = new HashSet<string>(
|
||||
extStr.Split(',').Select(s => s.Trim().StartsWith('.') ? s.Trim() : "." + s.Trim()),
|
||||
@@ -128,11 +128,11 @@ public class TaskTrackerTool : IAgentTool
|
||||
|
||||
private static ToolResult AddTask(JsonElement args, AgentContext context)
|
||||
{
|
||||
var title = args.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
|
||||
var title = args.SafeTryGetProperty("title", out var t) ? t.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(title))
|
||||
return ToolResult.Fail("'title'이 필요합니다.");
|
||||
|
||||
var priority = args.TryGetProperty("priority", out var p) ? p.GetString() ?? "medium" : "medium";
|
||||
var priority = args.SafeTryGetProperty("priority", out var p) ? p.SafeGetString() ?? "medium" : "medium";
|
||||
var tasks = LoadTasks(context);
|
||||
|
||||
var maxId = tasks.Count > 0 ? tasks.Max(t2 => t2.Id) : 0;
|
||||
@@ -167,7 +167,7 @@ public class TaskTrackerTool : IAgentTool
|
||||
|
||||
private static ToolResult MarkDone(JsonElement args, AgentContext context)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
if (!args.SafeTryGetProperty("id", out var idEl))
|
||||
return ToolResult.Fail("'id'가 필요합니다.");
|
||||
var id = idEl.GetInt32();
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class TaskUpdateTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (!args.TryGetProperty("id", out var idEl))
|
||||
if (!args.SafeTryGetProperty("id", out var idEl))
|
||||
return Task.FromResult(ToolResult.Fail("id is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
@@ -37,27 +37,27 @@ public sealed class TaskUpdateTool : IAgentTool
|
||||
if (task == null)
|
||||
return Task.FromResult(ToolResult.Fail($"Task #{id} not found."));
|
||||
|
||||
if (args.TryGetProperty("status", out var statusEl))
|
||||
if (args.SafeTryGetProperty("status", out var statusEl))
|
||||
{
|
||||
var status = (statusEl.GetString() ?? "").Trim().ToLowerInvariant();
|
||||
var status = (statusEl.SafeGetString() ?? "").Trim().ToLowerInvariant();
|
||||
if (!TaskBoardStore.IsValidStatus(status))
|
||||
return Task.FromResult(ToolResult.Fail("Invalid status."));
|
||||
task.Status = status;
|
||||
}
|
||||
|
||||
if (args.TryGetProperty("title", out var titleEl))
|
||||
if (args.SafeTryGetProperty("title", out var titleEl))
|
||||
{
|
||||
var title = (titleEl.GetString() ?? "").Trim();
|
||||
var title = (titleEl.SafeGetString() ?? "").Trim();
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
task.Title = title;
|
||||
}
|
||||
|
||||
if (args.TryGetProperty("description", out var descEl))
|
||||
task.Description = (descEl.GetString() ?? "").Trim();
|
||||
if (args.SafeTryGetProperty("description", out var descEl))
|
||||
task.Description = (descEl.SafeGetString() ?? "").Trim();
|
||||
|
||||
if (args.TryGetProperty("priority", out var priEl))
|
||||
if (args.SafeTryGetProperty("priority", out var priEl))
|
||||
{
|
||||
var priority = (priEl.GetString() ?? "").Trim().ToLowerInvariant();
|
||||
var priority = (priEl.SafeGetString() ?? "").Trim().ToLowerInvariant();
|
||||
if (!TaskBoardStore.IsValidPriority(priority))
|
||||
return Task.FromResult(ToolResult.Fail("Invalid priority."));
|
||||
task.Priority = priority;
|
||||
|
||||
@@ -20,13 +20,13 @@ public sealed class TeamCreateTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var name = args.TryGetProperty("name", out var n) ? (n.GetString() ?? "").Trim() : "";
|
||||
var name = args.SafeTryGetProperty("name", out var n) ? (n.SafeGetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return Task.FromResult(ToolResult.Fail("name is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var role = args.TryGetProperty("role", out var r) ? (r.GetString() ?? "worker").Trim() : "worker";
|
||||
var role = args.SafeTryGetProperty("role", out var r) ? (r.SafeGetString() ?? "worker").Trim() : "worker";
|
||||
var members = TeamStore.Load(context.WorkFolder);
|
||||
var member = new TeamStore.Member { Name = name, Role = role };
|
||||
members.Add(member);
|
||||
|
||||
@@ -24,8 +24,8 @@ public sealed class TeamDeleteTool : IAgentTool
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
return Task.FromResult(ToolResult.Fail("valid WorkFolder is required."));
|
||||
|
||||
var id = args.TryGetProperty("id", out var idEl) ? (idEl.GetString() ?? "").Trim() : "";
|
||||
var name = args.TryGetProperty("name", out var nameEl) ? (nameEl.GetString() ?? "").Trim() : "";
|
||||
var id = args.SafeTryGetProperty("id", out var idEl) ? (idEl.SafeGetString() ?? "").Trim() : "";
|
||||
var name = args.SafeTryGetProperty("name", out var nameEl) ? (nameEl.SafeGetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(id) && string.IsNullOrWhiteSpace(name))
|
||||
return Task.FromResult(ToolResult.Fail("id or name is required."));
|
||||
|
||||
|
||||
@@ -51,25 +51,25 @@ public class TemplateRenderTool : IAgentTool
|
||||
{
|
||||
// 템플릿 텍스트 로드
|
||||
string template;
|
||||
if (args.TryGetProperty("template_path", out var tpEl) && !string.IsNullOrEmpty(tpEl.GetString()))
|
||||
if (args.SafeTryGetProperty("template_path", out var tpEl) && !string.IsNullOrEmpty(tpEl.SafeGetString()))
|
||||
{
|
||||
var templatePath = FileReadTool.ResolvePath(tpEl.GetString()!, context.WorkFolder);
|
||||
var templatePath = FileReadTool.ResolvePath(tpEl.SafeGetString()!, context.WorkFolder);
|
||||
if (!context.IsPathAllowed(templatePath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {templatePath}");
|
||||
if (!File.Exists(templatePath))
|
||||
return ToolResult.Fail($"템플릿 파일 없음: {templatePath}");
|
||||
template = (await TextFileCodec.ReadAllTextAsync(templatePath, ct)).Text;
|
||||
}
|
||||
else if (args.TryGetProperty("template_text", out var ttEl) && !string.IsNullOrEmpty(ttEl.GetString()))
|
||||
else if (args.SafeTryGetProperty("template_text", out var ttEl) && !string.IsNullOrEmpty(ttEl.SafeGetString()))
|
||||
{
|
||||
template = ttEl.GetString()!;
|
||||
template = ttEl.SafeGetString()!;
|
||||
}
|
||||
else
|
||||
{
|
||||
return ToolResult.Fail("template_path 또는 template_text가 필요합니다.");
|
||||
}
|
||||
|
||||
if (!args.TryGetProperty("variables", out var varsEl))
|
||||
if (!args.SafeTryGetProperty("variables", out var varsEl))
|
||||
return ToolResult.Fail("variables가 필요합니다.");
|
||||
|
||||
try
|
||||
@@ -78,9 +78,9 @@ public class TemplateRenderTool : IAgentTool
|
||||
var rendered = Render(template, varsEl);
|
||||
|
||||
// 출력
|
||||
if (args.TryGetProperty("output_path", out var opEl) && !string.IsNullOrEmpty(opEl.GetString()))
|
||||
if (args.SafeTryGetProperty("output_path", out var opEl) && !string.IsNullOrEmpty(opEl.SafeGetString()))
|
||||
{
|
||||
var outputPath = FileReadTool.ResolvePath(opEl.GetString()!, context.WorkFolder);
|
||||
var outputPath = FileReadTool.ResolvePath(opEl.SafeGetString()!, context.WorkFolder);
|
||||
if (context.ActiveTab == "Cowork") outputPath = AgentContext.EnsureTimestampedPath(outputPath);
|
||||
if (!context.IsPathAllowed(outputPath))
|
||||
return ToolResult.Fail($"경로 접근 차단: {outputPath}");
|
||||
@@ -117,7 +117,7 @@ public class TemplateRenderTool : IAgentTool
|
||||
var key = match.Groups[1].Value;
|
||||
var body = match.Groups[2].Value;
|
||||
|
||||
if (!variables.TryGetProperty(key, out var val)) return "";
|
||||
if (!variables.SafeTryGetProperty(key, out var val)) return "";
|
||||
|
||||
if (val.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
@@ -167,11 +167,11 @@ public class TemplateRenderTool : IAgentTool
|
||||
var key = match.Groups[1].Value;
|
||||
var body = match.Groups[2].Value;
|
||||
|
||||
if (!variables.TryGetProperty(key, out var val)) return body;
|
||||
if (!variables.SafeTryGetProperty(key, out var val)) return body;
|
||||
if (val.ValueKind == JsonValueKind.False ||
|
||||
val.ValueKind == JsonValueKind.Null ||
|
||||
(val.ValueKind == JsonValueKind.Array && val.GetArrayLength() == 0) ||
|
||||
(val.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(val.GetString())))
|
||||
(val.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(val.SafeGetString())))
|
||||
return body;
|
||||
|
||||
return "";
|
||||
@@ -188,11 +188,11 @@ public class TemplateRenderTool : IAgentTool
|
||||
return Regex.Replace(text, @"\{\{(\w+)\}\}", match =>
|
||||
{
|
||||
var key = match.Groups[1].Value;
|
||||
if (!variables.TryGetProperty(key, out var val)) return match.Value;
|
||||
if (!variables.SafeTryGetProperty(key, out var val)) return match.Value;
|
||||
|
||||
return val.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => val.GetString() ?? "",
|
||||
JsonValueKind.String => val.SafeGetString() ?? "",
|
||||
JsonValueKind.Number => val.ToString(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
|
||||
@@ -184,7 +184,7 @@ public static class TemplateService
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: #f5f5f7; color: #1d1d1f; line-height: 1.75; padding: 48px 24px; }
|
||||
.container { max-width: 880px; margin: 0 auto; background: #fff;
|
||||
.container { max-width: 1080px; margin: 0 auto; background: #fff;
|
||||
border-radius: 16px; padding: 56px 52px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06); }
|
||||
h1 { font-size: 28px; font-weight: 700; letter-spacing: -0.5px; color: #1d1d1f; margin-bottom: 4px; }
|
||||
@@ -220,7 +220,7 @@ public static class TemplateService
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
|
||||
background: #eef1f5; color: #1e293b; line-height: 1.7; padding: 40px 20px; }
|
||||
.container { max-width: 900px; margin: 0 auto; background: #fff;
|
||||
.container { max-width: 1080px; margin: 0 auto; background: #fff;
|
||||
border-radius: 8px; padding: 48px;
|
||||
box-shadow: 0 1px 8px rgba(0,0,0,0.08);
|
||||
border-top: 4px solid #1e3a5f; }
|
||||
@@ -260,7 +260,7 @@ public static class TemplateService
|
||||
body { font-family: 'Poppins', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; color: #2d3748; line-height: 1.75; padding: 48px 24px; }
|
||||
.container { max-width: 880px; margin: 0 auto; background: rgba(255,255,255,0.95);
|
||||
.container { max-width: 1080px; margin: 0 auto; background: rgba(255,255,255,0.95);
|
||||
backdrop-filter: blur(20px); border-radius: 20px; padding: 52px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
|
||||
h1 { font-size: 30px; font-weight: 700;
|
||||
@@ -338,7 +338,7 @@ public static class TemplateService
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Source Sans 3', 'Malgun Gothic', sans-serif;
|
||||
background: #faf8f5; color: #3d3929; line-height: 1.75; padding: 48px 24px; }
|
||||
.container { max-width: 860px; margin: 0 auto; background: #fff;
|
||||
.container { max-width: 1080px; margin: 0 auto; background: #fff;
|
||||
border-radius: 4px; padding: 56px 52px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||
border: 1px solid #e8e4dd; }
|
||||
@@ -377,7 +377,7 @@ public static class TemplateService
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Inter', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: #0d1117; color: #e6edf3; line-height: 1.75; padding: 48px 24px; }
|
||||
.container { max-width: 880px; margin: 0 auto; background: #161b22;
|
||||
.container { max-width: 1080px; margin: 0 auto; background: #161b22;
|
||||
border-radius: 12px; padding: 52px;
|
||||
border: 1px solid #30363d;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3); }
|
||||
@@ -418,7 +418,7 @@ public static class TemplateService
|
||||
body { font-family: 'Nunito', 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 50%, #ffecd2 100%);
|
||||
min-height: 100vh; color: #2d3436; line-height: 1.75; padding: 48px 24px; }
|
||||
.container { max-width: 880px; margin: 0 auto; background: #fff;
|
||||
.container { max-width: 1080px; margin: 0 auto; background: #fff;
|
||||
border-radius: 20px; padding: 52px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,0.08); }
|
||||
h1 { font-size: 30px; font-weight: 800; color: #e17055; margin-bottom: 4px; }
|
||||
@@ -457,7 +457,7 @@ public static class TemplateService
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Segoe UI', 'Malgun Gothic', Arial, sans-serif;
|
||||
background: #f2f2f2; color: #333; line-height: 1.65; padding: 32px 20px; }
|
||||
.container { max-width: 900px; margin: 0 auto; background: #fff; padding: 0;
|
||||
.container { max-width: 1080px; margin: 0 auto; background: #fff; padding: 0;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||
.header-bar { background: #003366; color: #fff; padding: 28px 40px 20px;
|
||||
border-bottom: 3px solid #ff6600; }
|
||||
@@ -499,7 +499,7 @@ public static class TemplateService
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
body { font-family: 'Open Sans', 'Malgun Gothic', sans-serif;
|
||||
background: #f0ece3; color: #2c2c2c; line-height: 1.7; padding: 48px 24px; }
|
||||
.container { max-width: 900px; margin: 0 auto; background: #fff;
|
||||
.container { max-width: 1080px; margin: 0 auto; background: #fff;
|
||||
border-radius: 2px; padding: 0; overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
|
||||
.hero { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
|
||||
@@ -46,7 +46,7 @@ public class TestLoopTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString() ?? "" : "";
|
||||
var action = args.SafeTryGetProperty("action", out var a) ? a.SafeGetString() ?? "" : "";
|
||||
|
||||
return action switch
|
||||
{
|
||||
@@ -60,7 +60,7 @@ public class TestLoopTool : IAgentTool
|
||||
|
||||
private static ToolResult GenerateTestSuggestion(JsonElement args, AgentContext context)
|
||||
{
|
||||
var filePath = args.TryGetProperty("file_path", out var f) ? f.GetString() ?? "" : "";
|
||||
var filePath = args.SafeTryGetProperty("file_path", out var f) ? f.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(filePath))
|
||||
return ToolResult.Fail("file_path가 필요합니다.");
|
||||
|
||||
@@ -146,7 +146,7 @@ public class TestLoopTool : IAgentTool
|
||||
|
||||
private static ToolResult AnalyzeTestOutput(JsonElement args)
|
||||
{
|
||||
var output = args.TryGetProperty("test_output", out var o) ? o.GetString() ?? "" : "";
|
||||
var output = args.SafeTryGetProperty("test_output", out var o) ? o.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(output))
|
||||
return ToolResult.Fail("test_output이 필요합니다.");
|
||||
|
||||
|
||||
@@ -60,12 +60,12 @@ public class TextSummarizeTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var source = args.GetProperty("source").GetString() ?? "";
|
||||
var maxLength = args.TryGetProperty("max_length", out var mlEl) && mlEl.TryGetInt32(out var ml) ? ml : 500;
|
||||
var style = args.TryGetProperty("style", out var stEl) ? stEl.GetString() ?? "bullet" : "bullet";
|
||||
var language = args.TryGetProperty("language", out var langEl) ? langEl.GetString() ?? "ko" : "ko";
|
||||
var focus = args.TryGetProperty("focus", out var focEl) ? focEl.GetString() ?? "" : "";
|
||||
var bySections = args.TryGetProperty("sections", out var secEl) && secEl.GetBoolean();
|
||||
var source = args.GetProperty("source").SafeGetString() ?? "";
|
||||
var maxLength = args.SafeTryGetProperty("max_length", out var mlEl) && mlEl.TryGetInt32(out var ml) ? ml : 500;
|
||||
var style = args.SafeTryGetProperty("style", out var stEl) ? stEl.SafeGetString() ?? "bullet" : "bullet";
|
||||
var language = args.SafeTryGetProperty("language", out var langEl) ? langEl.SafeGetString() ?? "ko" : "ko";
|
||||
var focus = args.SafeTryGetProperty("focus", out var focEl) ? focEl.SafeGetString() ?? "" : "";
|
||||
var bySections = args.SafeTryGetProperty("sections", out var secEl) && secEl.GetBoolean();
|
||||
|
||||
string text;
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class TodoWriteTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var actionEl) ? (actionEl.GetString() ?? "").Trim().ToLowerInvariant() : "";
|
||||
var action = args.SafeTryGetProperty("action", out var actionEl) ? (actionEl.SafeGetString() ?? "").Trim().ToLowerInvariant() : "";
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
return Task.FromResult(ToolResult.Fail("action is required."));
|
||||
if (string.IsNullOrWhiteSpace(context.WorkFolder) || !Directory.Exists(context.WorkFolder))
|
||||
@@ -49,7 +49,7 @@ public sealed class TodoWriteTool : IAgentTool
|
||||
|
||||
private static ToolResult Add(string todoPath, JsonElement args)
|
||||
{
|
||||
var text = args.TryGetProperty("text", out var textEl) ? (textEl.GetString() ?? "").Trim() : "";
|
||||
var text = args.SafeTryGetProperty("text", out var textEl) ? (textEl.SafeGetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return ToolResult.Fail("text is required for add.");
|
||||
|
||||
@@ -86,7 +86,7 @@ public sealed class TodoWriteTool : IAgentTool
|
||||
{
|
||||
if (!File.Exists(todoPath))
|
||||
return ToolResult.Fail("TODO file does not exist.");
|
||||
if (!args.TryGetProperty("index", out var indexEl))
|
||||
if (!args.SafeTryGetProperty("index", out var indexEl))
|
||||
return ToolResult.Fail("index is required for done.");
|
||||
|
||||
var targetIndex = indexEl.GetInt32();
|
||||
|
||||
@@ -31,12 +31,12 @@ public sealed class ToolSearchTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var query = args.TryGetProperty("query", out var queryEl) ? (queryEl.GetString() ?? "").Trim() : "";
|
||||
var query = args.SafeTryGetProperty("query", out var queryEl) ? (queryEl.SafeGetString() ?? "").Trim() : "";
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return Task.FromResult(ToolResult.Fail("query is required."));
|
||||
|
||||
var limit = args.TryGetProperty("limit", out var limitEl) ? Math.Clamp(limitEl.GetInt32(), 1, 30) : 10;
|
||||
var includeDescription = args.TryGetProperty("include_description", out var descEl)
|
||||
var limit = args.SafeTryGetProperty("limit", out var limitEl) ? Math.Clamp(limitEl.GetInt32(), 1, 30) : 10;
|
||||
var includeDescription = args.SafeTryGetProperty("include_description", out var descEl)
|
||||
&& descEl.ValueKind == JsonValueKind.True;
|
||||
|
||||
var queryTokens = query.Split([' ', '-', '_', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
|
||||
@@ -38,15 +38,15 @@ public class UserAskTool : IAgentTool
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var question = args.GetProperty("question").GetString() ?? "";
|
||||
var defaultVal = args.TryGetProperty("default_value", out var dv) ? dv.GetString() ?? "" : "";
|
||||
var question = args.GetProperty("question").SafeGetString() ?? "";
|
||||
var defaultVal = args.SafeTryGetProperty("default_value", out var dv) ? dv.SafeGetString() ?? "" : "";
|
||||
|
||||
var options = new List<string>();
|
||||
if (args.TryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array)
|
||||
if (args.SafeTryGetProperty("options", out var opts) && opts.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var o in opts.EnumerateArray())
|
||||
{
|
||||
var s = o.GetString();
|
||||
var s = o.SafeGetString();
|
||||
if (!string.IsNullOrEmpty(s)) options.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
233
src/AxCopilot/Services/Agent/XlsxToHtmlConverter.cs
Normal file
233
src/AxCopilot/Services/Agent/XlsxToHtmlConverter.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Spreadsheet;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>XLSX 파일을 HTML 테이블로 변환하여 WebView2에서 미리보기합니다.</summary>
|
||||
internal static class XlsxToHtmlConverter
|
||||
{
|
||||
private const int MaxRows = 500;
|
||||
private const int MaxCols = 50;
|
||||
|
||||
public static string Convert(string xlsxPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = SpreadsheetDocument.Open(xlsxPath, false);
|
||||
var workbookPart = doc.WorkbookPart;
|
||||
if (workbookPart == null)
|
||||
return WrapHtml("<p style='color:#888'>스프레드시트 내용이 없습니다.</p>");
|
||||
|
||||
var sheets = workbookPart.Workbook?.Sheets?.Elements<Sheet>().ToList();
|
||||
if (sheets == null || sheets.Count == 0)
|
||||
return WrapHtml("<p style='color:#888'>시트가 없습니다.</p>");
|
||||
|
||||
var sharedStrings = workbookPart.SharedStringTablePart?.SharedStringTable;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// 시트 탭 UI
|
||||
if (sheets.Count > 1)
|
||||
{
|
||||
sb.AppendLine("<div class='sheet-tabs'>");
|
||||
for (int i = 0; i < sheets.Count; i++)
|
||||
{
|
||||
var name = sheets[i].Name?.Value ?? $"Sheet{i + 1}";
|
||||
var activeClass = i == 0 ? " active" : "";
|
||||
sb.AppendLine($"<button class='sheet-tab{activeClass}' onclick='showSheet({i})'>{Escape(name)}</button>");
|
||||
}
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
// 시트별 테이블
|
||||
for (int si = 0; si < sheets.Count; si++)
|
||||
{
|
||||
var sheet = sheets[si];
|
||||
var partId = sheet.Id?.Value;
|
||||
if (string.IsNullOrEmpty(partId)) continue;
|
||||
|
||||
WorksheetPart sheetPart;
|
||||
try { sheetPart = (WorksheetPart)workbookPart.GetPartById(partId); }
|
||||
catch { continue; }
|
||||
|
||||
var display = si == 0 ? "block" : "none";
|
||||
sb.AppendLine($"<div class='sheet-content' id='sheet-{si}' style='display:{display}'>");
|
||||
sb.AppendLine(RenderSheet(sheetPart, sharedStrings));
|
||||
sb.AppendLine("</div>");
|
||||
}
|
||||
|
||||
return WrapHtml(sb.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return WrapHtml($"<p style='color:#e53e3e'>XLSX 파싱 오류: {Escape(ex.Message)}</p>");
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderSheet(WorksheetPart sheetPart, SharedStringTable? sharedStrings)
|
||||
{
|
||||
var sheetData = sheetPart.Worksheet?.GetFirstChild<SheetData>();
|
||||
if (sheetData == null)
|
||||
return "<p style='color:#888'>시트 데이터가 없습니다.</p>";
|
||||
|
||||
var rows = sheetData.Elements<Row>().Take(MaxRows + 1).ToList();
|
||||
if (rows.Count == 0)
|
||||
return "<p style='color:#888'>데이터가 없습니다.</p>";
|
||||
|
||||
// 병합 셀 정보
|
||||
var mergeCells = sheetPart.Worksheet?.GetFirstChild<MergeCells>()
|
||||
?.Elements<MergeCell>().ToList() ?? new List<MergeCell>();
|
||||
var mergeMap = BuildMergeMap(mergeCells);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<div style='overflow-x:auto'><table>");
|
||||
|
||||
var rowIndex = 0;
|
||||
foreach (var row in rows.Take(MaxRows))
|
||||
{
|
||||
rowIndex++;
|
||||
sb.Append(rowIndex == 1 ? "<tr class='header'>" : "<tr>");
|
||||
|
||||
var cells = row.Elements<Cell>().Take(MaxCols).ToList();
|
||||
foreach (var cell in cells)
|
||||
{
|
||||
var cellRef = cell.CellReference?.Value ?? "";
|
||||
if (mergeMap.TryGetValue(cellRef, out var merge))
|
||||
{
|
||||
if (merge.IsHidden) continue; // 병합된 셀 건너뛰기
|
||||
var spanAttrs = "";
|
||||
if (merge.ColSpan > 1) spanAttrs += $" colspan='{merge.ColSpan}'";
|
||||
if (merge.RowSpan > 1) spanAttrs += $" rowspan='{merge.RowSpan}'";
|
||||
var tag = rowIndex == 1 ? "th" : "td";
|
||||
sb.Append($"<{tag}{spanAttrs}>{Escape(GetCellValue(cell, sharedStrings))}</{tag}>");
|
||||
}
|
||||
else
|
||||
{
|
||||
var tag = rowIndex == 1 ? "th" : "td";
|
||||
sb.Append($"<{tag}>{Escape(GetCellValue(cell, sharedStrings))}</{tag}>");
|
||||
}
|
||||
}
|
||||
sb.AppendLine("</tr>");
|
||||
}
|
||||
|
||||
sb.AppendLine("</table></div>");
|
||||
|
||||
if (rows.Count > MaxRows)
|
||||
sb.AppendLine($"<p class='truncated'>... {MaxRows}행까지 표시 (전체 데이터는 Excel에서 확인)</p>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GetCellValue(Cell cell, SharedStringTable? sharedStrings)
|
||||
{
|
||||
var value = cell.CellValue?.Text ?? cell.InnerText ?? "";
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
|
||||
if (cell.DataType?.Value == CellValues.SharedString && sharedStrings != null)
|
||||
{
|
||||
if (int.TryParse(value, out var idx) && idx >= 0 && idx < sharedStrings.Count())
|
||||
return sharedStrings.ElementAt(idx).InnerText;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private sealed record MergeCellInfo(int ColSpan, int RowSpan, bool IsHidden);
|
||||
|
||||
private static Dictionary<string, MergeCellInfo> BuildMergeMap(List<MergeCell> mergeCells)
|
||||
{
|
||||
var map = new Dictionary<string, MergeCellInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var mc in mergeCells)
|
||||
{
|
||||
var range = mc.Reference?.Value;
|
||||
if (string.IsNullOrEmpty(range)) continue;
|
||||
var parts = range.Split(':');
|
||||
if (parts.Length != 2) continue;
|
||||
|
||||
var (startCol, startRow) = ParseCellRef(parts[0]);
|
||||
var (endCol, endRow) = ParseCellRef(parts[1]);
|
||||
var colSpan = endCol - startCol + 1;
|
||||
var rowSpan = endRow - startRow + 1;
|
||||
|
||||
// 시작 셀: span 정보 포함
|
||||
map[parts[0]] = new MergeCellInfo(colSpan, rowSpan, false);
|
||||
|
||||
// 나머지 셀: 숨김
|
||||
for (int r = startRow; r <= endRow; r++)
|
||||
for (int c = startCol; c <= endCol; c++)
|
||||
{
|
||||
var cellRef = ColumnIndexToLetter(c) + r;
|
||||
if (!string.Equals(cellRef, parts[0], StringComparison.OrdinalIgnoreCase))
|
||||
map[cellRef] = new MergeCellInfo(1, 1, true);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static (int Col, int Row) ParseCellRef(string cellRef)
|
||||
{
|
||||
int col = 0, i = 0;
|
||||
while (i < cellRef.Length && char.IsLetter(cellRef[i]))
|
||||
{
|
||||
col = col * 26 + (char.ToUpper(cellRef[i]) - 'A' + 1);
|
||||
i++;
|
||||
}
|
||||
int.TryParse(cellRef[i..], out var row);
|
||||
return (col, row);
|
||||
}
|
||||
|
||||
private static string ColumnIndexToLetter(int col)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
while (col > 0)
|
||||
{
|
||||
col--;
|
||||
sb.Insert(0, (char)('A' + col % 26));
|
||||
col /= 26;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string Escape(string text)
|
||||
=> System.Net.WebUtility.HtmlEncode(text);
|
||||
|
||||
private static string WrapHtml(string body)
|
||||
{
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang='ko'>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{ font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; background: #fff; color: #1a1a1a;
|
||||
line-height: 1.5; padding: 16px; font-size: 12.5px; }}
|
||||
.sheet-tabs {{ display: flex; gap: 2px; margin-bottom: 12px; border-bottom: 2px solid #e5e7eb; padding-bottom: 0; }}
|
||||
.sheet-tab {{ padding: 6px 14px; border: none; background: #f3f4f6; cursor: pointer;
|
||||
font-size: 11.5px; border-radius: 6px 6px 0 0; color: #6b7280; }}
|
||||
.sheet-tab.active {{ background: #fff; color: #2563eb; font-weight: 600;
|
||||
border-bottom: 2px solid #2563eb; margin-bottom: -2px; }}
|
||||
.sheet-tab:hover {{ background: #e5e7eb; }}
|
||||
table {{ width: 100%; border-collapse: collapse; font-size: 12px; }}
|
||||
th, td {{ border: 1px solid #d1d5db; padding: 5px 8px; text-align: left; white-space: nowrap; }}
|
||||
th, .header td {{ background: #f3f4f6; font-weight: 600; position: sticky; top: 0; }}
|
||||
tr:nth-child(even) {{ background: #f9fafb; }}
|
||||
.truncated {{ color: #9ca3af; font-size: 11px; margin-top: 8px; font-style: italic; }}
|
||||
</style>
|
||||
<script>
|
||||
function showSheet(idx) {{
|
||||
document.querySelectorAll('.sheet-content').forEach(el => el.style.display = 'none');
|
||||
document.querySelectorAll('.sheet-tab').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById('sheet-' + idx).style.display = 'block';
|
||||
document.querySelectorAll('.sheet-tab')[idx].classList.add('active');
|
||||
}}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{body}
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,10 @@ public class XmlTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var xmlStr = args.TryGetProperty("xml", out var x) ? x.GetString() ?? "" : "";
|
||||
var rawPath = args.TryGetProperty("path", out var pv) ? pv.GetString() ?? "" : "";
|
||||
var expression = args.TryGetProperty("expression", out var ex) ? ex.GetString() ?? "" : "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
var xmlStr = args.SafeTryGetProperty("xml", out var x) ? x.SafeGetString() ?? "" : "";
|
||||
var rawPath = args.SafeTryGetProperty("path", out var pv) ? pv.SafeGetString() ?? "" : "";
|
||||
var expression = args.SafeTryGetProperty("expression", out var ex) ? ex.SafeGetString() ?? "" : "";
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -49,8 +49,8 @@ public class ZipTool : IAgentTool
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
var zipPath = args.GetProperty("zip_path").GetString() ?? "";
|
||||
var action = args.GetProperty("action").SafeGetString() ?? "";
|
||||
var zipPath = args.GetProperty("zip_path").SafeGetString() ?? "";
|
||||
|
||||
if (!Path.IsPathRooted(zipPath))
|
||||
zipPath = Path.Combine(context.WorkFolder, zipPath);
|
||||
@@ -73,10 +73,10 @@ public class ZipTool : IAgentTool
|
||||
|
||||
private static ToolResult Compress(JsonElement args, string zipPath, AgentContext context)
|
||||
{
|
||||
if (!args.TryGetProperty("source_path", out var sp))
|
||||
if (!args.SafeTryGetProperty("source_path", out var sp))
|
||||
return ToolResult.Fail("'source_path' is required for compress action");
|
||||
|
||||
var sourcePath = sp.GetString() ?? "";
|
||||
var sourcePath = sp.SafeGetString() ?? "";
|
||||
if (!Path.IsPathRooted(sourcePath))
|
||||
sourcePath = Path.Combine(context.WorkFolder, sourcePath);
|
||||
|
||||
@@ -107,8 +107,8 @@ public class ZipTool : IAgentTool
|
||||
if (!File.Exists(zipPath))
|
||||
return ToolResult.Fail($"Zip file not found: {zipPath}");
|
||||
|
||||
var destPath = args.TryGetProperty("dest_path", out var dp)
|
||||
? dp.GetString() ?? "" : "";
|
||||
var destPath = args.SafeTryGetProperty("dest_path", out var dp)
|
||||
? dp.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(destPath))
|
||||
destPath = Path.Combine(Path.GetDirectoryName(zipPath) ?? context.WorkFolder,
|
||||
Path.GetFileNameWithoutExtension(zipPath));
|
||||
|
||||
@@ -57,6 +57,14 @@ public static class AgentPerformanceLogService
|
||||
}
|
||||
}
|
||||
|
||||
public static void LogExplorationBreadth(
|
||||
string conversationId,
|
||||
string tab,
|
||||
object detail)
|
||||
{
|
||||
LogMetric("agent_loop", "exploration_breadth", conversationId, tab, 0, detail);
|
||||
}
|
||||
|
||||
public static string GetPerformanceFolder() => PerfDir;
|
||||
}
|
||||
|
||||
|
||||
@@ -255,6 +255,25 @@ public class ChatStorageService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>지정한 탭에 속한 대화만 삭제합니다.</summary>
|
||||
public int DeleteAllByTab(string tab)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tab)) return DeleteAll();
|
||||
var metas = LoadAllMeta();
|
||||
var targets = metas
|
||||
.Where(m => string.Equals(m.Tab, tab, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(m => m.Id)
|
||||
.ToList();
|
||||
|
||||
int count = 0;
|
||||
foreach (var id in targets)
|
||||
{
|
||||
try { Delete(id); count++; }
|
||||
catch { /* 개별 삭제 실패 무시 */ }
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>보관 기간을 초과한 대화를 삭제합니다 (핀 고정 제외).</summary>
|
||||
public int PurgeExpired(int retentionDays)
|
||||
{
|
||||
|
||||
@@ -108,6 +108,25 @@ public sealed class DraftQueueProcessorService
|
||||
public int ClearFailed(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
|
||||
=> ClearByState(session, tab, "failed", storage);
|
||||
|
||||
/// <summary>대기 중인 항목을 모두 제거합니다 (중지 시 사용).</summary>
|
||||
public int ClearQueued(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
|
||||
=> ClearByState(session, tab, "queued", storage);
|
||||
|
||||
/// <summary>실행 중인 항목을 실패로 전환합니다 (중지 시 사용).</summary>
|
||||
public int CancelRunning(ChatSessionStateService? session, string tab, ChatStorageService? storage = null)
|
||||
{
|
||||
if (session == null) return 0;
|
||||
int count = 0;
|
||||
foreach (var item in session.GetDraftQueueItems(tab)
|
||||
.Where(x => string.Equals(x.State, "running", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList())
|
||||
{
|
||||
if (session.RemoveDraft(tab, item.Id, storage))
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int ClearByState(ChatSessionStateService? session, string tab, string state, ChatStorageService? storage)
|
||||
{
|
||||
if (session == null)
|
||||
|
||||
@@ -162,22 +162,22 @@ public partial class LlmService
|
||||
var root = doc.RootElement;
|
||||
|
||||
// 토큰 사용량
|
||||
if (root.TryGetProperty("usage", out var usage))
|
||||
if (root.SafeTryGetProperty("usage", out var usage))
|
||||
TryParseSigmoidUsageFromElement(usage);
|
||||
|
||||
// 컨텐츠 블록 파싱
|
||||
var blocks = new List<ContentBlock>();
|
||||
if (root.TryGetProperty("content", out var content))
|
||||
if (root.SafeTryGetProperty("content", out var content))
|
||||
{
|
||||
foreach (var block in content.EnumerateArray())
|
||||
{
|
||||
var type = block.TryGetProperty("type", out var tp) ? tp.GetString() : "";
|
||||
var type = block.SafeTryGetProperty("type", out var tp) ? tp.SafeGetString() : "";
|
||||
if (type == "text")
|
||||
{
|
||||
blocks.Add(new ContentBlock
|
||||
{
|
||||
Type = "text",
|
||||
Text = block.TryGetProperty("text", out var txt) ? txt.GetString() ?? "" : ""
|
||||
Text = block.SafeTryGetProperty("text", out var txt) ? txt.SafeGetString() ?? "" : ""
|
||||
});
|
||||
}
|
||||
else if (type == "tool_use")
|
||||
@@ -185,9 +185,9 @@ public partial class LlmService
|
||||
blocks.Add(new ContentBlock
|
||||
{
|
||||
Type = "tool_use",
|
||||
ToolName = block.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "",
|
||||
ToolId = block.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "",
|
||||
ToolInput = block.TryGetProperty("input", out var inp) ? inp.Clone() : null
|
||||
ToolName = block.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "",
|
||||
ToolId = block.SafeTryGetProperty("id", out var bid) ? bid.SafeGetString() ?? "" : "",
|
||||
ToolInput = block.SafeTryGetProperty("input", out var inp) ? inp.Clone() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -220,8 +220,8 @@ public partial class LlmService
|
||||
new
|
||||
{
|
||||
type = "tool_result",
|
||||
tool_use_id = root.TryGetProperty("tool_use_id", out var tuid) ? tuid.GetString() : "",
|
||||
content = root.TryGetProperty("content", out var tcont) ? tcont.GetString() : ""
|
||||
tool_use_id = root.SafeTryGetProperty("tool_use_id", out var tuid) ? tuid.SafeGetString() : "",
|
||||
content = root.SafeTryGetProperty("content", out var tcont) ? tcont.SafeGetString() : ""
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -236,20 +236,20 @@ public partial class LlmService
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(m.Content);
|
||||
if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr)) throw new Exception();
|
||||
if (!doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocksArr)) throw new Exception();
|
||||
var contentList = new List<object>();
|
||||
foreach (var b in blocksArr.EnumerateArray())
|
||||
{
|
||||
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : "";
|
||||
var bType = b.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString() : "";
|
||||
if (bType == "text")
|
||||
contentList.Add(new { type = "text", text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" });
|
||||
contentList.Add(new { type = "text", text = b.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "" });
|
||||
else if (bType == "tool_use")
|
||||
contentList.Add(new
|
||||
{
|
||||
type = "tool_use",
|
||||
id = b.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "",
|
||||
name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "",
|
||||
input = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
|
||||
id = b.SafeTryGetProperty("id", out var bid) ? bid.SafeGetString() ?? "" : "",
|
||||
name = b.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "",
|
||||
input = b.SafeTryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
|
||||
});
|
||||
}
|
||||
msgs.Add(new { role = "assistant", content = contentList });
|
||||
@@ -347,26 +347,26 @@ public partial class LlmService
|
||||
TryParseGeminiUsage(root);
|
||||
|
||||
var blocks = new List<ContentBlock>();
|
||||
if (root.TryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0)
|
||||
if (root.SafeTryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0)
|
||||
{
|
||||
var firstCandidate = candidates[0];
|
||||
if (firstCandidate.TryGetProperty("content", out var contentObj) &&
|
||||
contentObj.TryGetProperty("parts", out var parts))
|
||||
if (firstCandidate.SafeTryGetProperty("content", out var contentObj) &&
|
||||
contentObj.SafeTryGetProperty("parts", out var parts))
|
||||
{
|
||||
foreach (var part in parts.EnumerateArray())
|
||||
{
|
||||
if (part.TryGetProperty("text", out var text))
|
||||
if (part.SafeTryGetProperty("text", out var text))
|
||||
{
|
||||
blocks.Add(new ContentBlock { Type = "text", Text = text.GetString() ?? "" });
|
||||
blocks.Add(new ContentBlock { Type = "text", Text = text.SafeGetString() ?? "" });
|
||||
}
|
||||
else if (part.TryGetProperty("functionCall", out var fc))
|
||||
else if (part.SafeTryGetProperty("functionCall", out var fc))
|
||||
{
|
||||
blocks.Add(new ContentBlock
|
||||
{
|
||||
Type = "tool_use",
|
||||
ToolName = fc.TryGetProperty("name", out var fcName) ? fcName.GetString() ?? "" : "",
|
||||
ToolName = fc.SafeTryGetProperty("name", out var fcName) ? fcName.SafeGetString() ?? "" : "",
|
||||
ToolId = Guid.NewGuid().ToString("N")[..12],
|
||||
ToolInput = fc.TryGetProperty("args", out var a) ? a.Clone() : null
|
||||
ToolInput = fc.SafeTryGetProperty("args", out var a) ? a.Clone() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -391,8 +391,8 @@ public partial class LlmService
|
||||
{
|
||||
using var doc = JsonDocument.Parse(m.Content);
|
||||
var root = doc.RootElement;
|
||||
var toolName = root.TryGetProperty("tool_name", out var tn) ? tn.GetString() ?? "" : "";
|
||||
var toolContent = root.TryGetProperty("content", out var tc) ? tc.GetString() ?? "" : "";
|
||||
var toolName = root.SafeTryGetProperty("tool_name", out var tn) ? tn.SafeGetString() ?? "" : "";
|
||||
var toolContent = root.SafeTryGetProperty("content", out var tc) ? tc.SafeGetString() ?? "" : "";
|
||||
contents.Add(new
|
||||
{
|
||||
role = "function",
|
||||
@@ -419,21 +419,21 @@ public partial class LlmService
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(m.Content);
|
||||
if (doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr))
|
||||
if (doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocksArr))
|
||||
{
|
||||
var parts = new List<object>();
|
||||
foreach (var b in blocksArr.EnumerateArray())
|
||||
{
|
||||
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : "";
|
||||
var bType = b.SafeTryGetProperty("type", out var bt) ? bt.SafeGetString() : "";
|
||||
if (bType == "text")
|
||||
parts.Add(new { text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" });
|
||||
parts.Add(new { text = b.SafeTryGetProperty("text", out var tx) ? tx.SafeGetString() ?? "" : "" });
|
||||
else if (bType == "tool_use")
|
||||
parts.Add(new
|
||||
{
|
||||
functionCall = new
|
||||
{
|
||||
name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "",
|
||||
args = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
|
||||
name = b.SafeTryGetProperty("name", out var nm) ? nm.SafeGetString() ?? "" : "",
|
||||
args = b.SafeTryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -639,13 +639,13 @@ public partial class LlmService
|
||||
json = json[braceStart..(braceEnd + 1)];
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var name = root.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
var name = root.SafeTryGetProperty("name", out var n) ? n.SafeGetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
|
||||
JsonElement? args = null;
|
||||
if (root.TryGetProperty("arguments", out var a))
|
||||
if (root.SafeTryGetProperty("arguments", out var a))
|
||||
args = a.Clone();
|
||||
else if (root.TryGetProperty("parameters", out var p))
|
||||
else if (root.SafeTryGetProperty("parameters", out var p))
|
||||
args = p.Clone();
|
||||
|
||||
return new ContentBlock
|
||||
@@ -702,8 +702,8 @@ public partial class LlmService
|
||||
msgs.Add(new
|
||||
{
|
||||
role = "tool",
|
||||
tool_call_id = root.GetProperty("tool_use_id").GetString(),
|
||||
content = root.GetProperty("content").GetString(),
|
||||
tool_call_id = root.GetProperty("tool_use_id").SafeGetString(),
|
||||
content = root.GetProperty("content").SafeGetString(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -721,19 +721,19 @@ public partial class LlmService
|
||||
var toolCallsList = new List<object>();
|
||||
foreach (var b in blocksArr.EnumerateArray())
|
||||
{
|
||||
var bType = b.GetProperty("type").GetString();
|
||||
var bType = b.GetProperty("type").SafeGetString();
|
||||
if (bType == "text")
|
||||
textContent = b.GetProperty("text").GetString() ?? "";
|
||||
textContent = b.GetProperty("text").SafeGetString() ?? "";
|
||||
else if (bType == "tool_use")
|
||||
{
|
||||
var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
|
||||
var argsJson = b.SafeTryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
|
||||
toolCallsList.Add(new
|
||||
{
|
||||
id = b.GetProperty("id").GetString() ?? "",
|
||||
id = b.GetProperty("id").SafeGetString() ?? "",
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = b.GetProperty("name").GetString() ?? "",
|
||||
name = b.GetProperty("name").SafeGetString() ?? "",
|
||||
arguments = argsJson,
|
||||
}
|
||||
});
|
||||
@@ -817,6 +817,8 @@ public partial class LlmService
|
||||
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
|
||||
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
|
||||
};
|
||||
// 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환)
|
||||
body["stream_options"] = new { include_usage = true };
|
||||
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
|
||||
// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응)
|
||||
if (forceToolCall)
|
||||
@@ -877,8 +879,8 @@ public partial class LlmService
|
||||
msgs.Add(new
|
||||
{
|
||||
role = "tool",
|
||||
tool_call_id = root.GetProperty("tool_use_id").GetString(),
|
||||
content = root.GetProperty("content").GetString(),
|
||||
tool_call_id = root.GetProperty("tool_use_id").SafeGetString(),
|
||||
content = root.GetProperty("content").SafeGetString(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -896,19 +898,19 @@ public partial class LlmService
|
||||
var toolCallsList = new List<object>();
|
||||
foreach (var b in blocksArr.EnumerateArray())
|
||||
{
|
||||
var bType = b.GetProperty("type").GetString();
|
||||
var bType = b.GetProperty("type").SafeGetString();
|
||||
if (bType == "text")
|
||||
textContent = b.GetProperty("text").GetString() ?? "";
|
||||
textContent = b.GetProperty("text").SafeGetString() ?? "";
|
||||
else if (bType == "tool_use")
|
||||
{
|
||||
var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
|
||||
var argsJson = b.SafeTryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
|
||||
toolCallsList.Add(new
|
||||
{
|
||||
id = b.GetProperty("id").GetString() ?? "",
|
||||
id = b.GetProperty("id").SafeGetString() ?? "",
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = b.GetProperty("name").GetString() ?? "",
|
||||
name = b.GetProperty("name").SafeGetString() ?? "",
|
||||
arguments = argsJson,
|
||||
}
|
||||
});
|
||||
@@ -1197,11 +1199,11 @@ public partial class LlmService
|
||||
TryParseOpenAiUsage(root);
|
||||
|
||||
if (usesIbmDeploymentApi &&
|
||||
root.TryGetProperty("status", out var statusEl) &&
|
||||
string.Equals(statusEl.GetString(), "error", StringComparison.OrdinalIgnoreCase))
|
||||
root.SafeTryGetProperty("status", out var statusEl) &&
|
||||
string.Equals(statusEl.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var detail = root.TryGetProperty("message", out var msgEl)
|
||||
? msgEl.GetString()
|
||||
var detail = root.SafeTryGetProperty("message", out var msgEl)
|
||||
? msgEl.SafeGetString()
|
||||
: "IBM vLLM 도구 호출 응답 오류";
|
||||
throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류");
|
||||
}
|
||||
@@ -1223,15 +1225,15 @@ public partial class LlmService
|
||||
}
|
||||
|
||||
if (usesIbmDeploymentApi &&
|
||||
root.TryGetProperty("results", out var resultsEl) &&
|
||||
root.SafeTryGetProperty("results", out var resultsEl) &&
|
||||
resultsEl.ValueKind == JsonValueKind.Array &&
|
||||
resultsEl.GetArrayLength() > 0)
|
||||
{
|
||||
var first = resultsEl[0];
|
||||
var generatedText = first.TryGetProperty("generated_text", out var generatedTextEl)
|
||||
? generatedTextEl.GetString()
|
||||
: first.TryGetProperty("output_text", out var outputTextEl)
|
||||
? outputTextEl.GetString()
|
||||
var generatedText = first.SafeTryGetProperty("generated_text", out var generatedTextEl)
|
||||
? generatedTextEl.SafeGetString()
|
||||
: first.SafeTryGetProperty("output_text", out var outputTextEl)
|
||||
? outputTextEl.SafeGetString()
|
||||
: null;
|
||||
if (!string.IsNullOrEmpty(generatedText))
|
||||
{
|
||||
@@ -1251,18 +1253,18 @@ public partial class LlmService
|
||||
}
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("choices", out var choicesEl) &&
|
||||
if (root.SafeTryGetProperty("choices", out var choicesEl) &&
|
||||
choicesEl.ValueKind == JsonValueKind.Array &&
|
||||
choicesEl.GetArrayLength() > 0)
|
||||
{
|
||||
var firstChoice = choicesEl[0];
|
||||
if (firstChoice.TryGetProperty("delta", out var deltaEl))
|
||||
if (firstChoice.SafeTryGetProperty("delta", out var deltaEl))
|
||||
{
|
||||
var emittedContent = false;
|
||||
if (deltaEl.TryGetProperty("content", out var contentEl) &&
|
||||
if (deltaEl.SafeTryGetProperty("content", out var contentEl) &&
|
||||
contentEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var chunk = contentEl.GetString();
|
||||
var chunk = contentEl.SafeGetString();
|
||||
if (!string.IsNullOrEmpty(chunk))
|
||||
{
|
||||
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, chunk);
|
||||
@@ -1272,20 +1274,20 @@ public partial class LlmService
|
||||
// Qwen3.5 thinking 모드 폴백: content가 비어있거나 없으면 reasoning_content 사용
|
||||
// else if가 아닌 독립 if — content 키가 존재하되 빈 문자열("")인 경우도 커버
|
||||
if (!emittedContent &&
|
||||
deltaEl.TryGetProperty("reasoning_content", out var reasoningEl) &&
|
||||
deltaEl.SafeTryGetProperty("reasoning_content", out var reasoningEl) &&
|
||||
reasoningEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var reasoningChunk = reasoningEl.GetString();
|
||||
var reasoningChunk = reasoningEl.SafeGetString();
|
||||
if (!string.IsNullOrEmpty(reasoningChunk))
|
||||
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, reasoningChunk);
|
||||
}
|
||||
|
||||
if (deltaEl.TryGetProperty("tool_calls", out var toolCallsEl) &&
|
||||
if (deltaEl.SafeTryGetProperty("tool_calls", out var toolCallsEl) &&
|
||||
toolCallsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var toolCallEl in toolCallsEl.EnumerateArray())
|
||||
{
|
||||
var index = toolCallEl.TryGetProperty("index", out var indexEl) &&
|
||||
var index = toolCallEl.SafeTryGetProperty("index", out var indexEl) &&
|
||||
indexEl.TryGetInt32(out var parsedIndex)
|
||||
? parsedIndex
|
||||
: toolAccumulators.Count;
|
||||
@@ -1296,18 +1298,18 @@ public partial class LlmService
|
||||
toolAccumulators[index] = acc;
|
||||
}
|
||||
|
||||
if (toolCallEl.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
|
||||
acc.Id = idEl.GetString() ?? acc.Id;
|
||||
if (toolCallEl.SafeTryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
|
||||
acc.Id = idEl.SafeGetString() ?? acc.Id;
|
||||
|
||||
if (toolCallEl.TryGetProperty("function", out var functionEl))
|
||||
if (toolCallEl.SafeTryGetProperty("function", out var functionEl))
|
||||
{
|
||||
if (functionEl.TryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
|
||||
acc.Name = nameEl.GetString() ?? acc.Name;
|
||||
if (functionEl.SafeTryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
|
||||
acc.Name = nameEl.SafeGetString() ?? acc.Name;
|
||||
|
||||
if (functionEl.TryGetProperty("arguments", out var argumentsEl))
|
||||
if (functionEl.SafeTryGetProperty("arguments", out var argumentsEl))
|
||||
{
|
||||
if (argumentsEl.ValueKind == JsonValueKind.String)
|
||||
acc.Arguments.Append(argumentsEl.GetString());
|
||||
acc.Arguments.Append(argumentsEl.SafeGetString());
|
||||
else if (argumentsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
acc.Arguments.Append(argumentsEl.GetRawText());
|
||||
}
|
||||
@@ -1320,7 +1322,7 @@ public partial class LlmService
|
||||
}
|
||||
}
|
||||
|
||||
if (firstChoice.TryGetProperty("message", out var messageEl))
|
||||
if (firstChoice.SafeTryGetProperty("message", out var messageEl))
|
||||
{
|
||||
if (TryExtractMessageToolBlocks(messageEl, out var messageText2, out var directToolBlocks2))
|
||||
{
|
||||
@@ -1358,14 +1360,14 @@ public partial class LlmService
|
||||
text = "";
|
||||
toolBlocks = new List<ContentBlock>();
|
||||
JsonElement message = messageOrRoot;
|
||||
if (messageOrRoot.TryGetProperty("message", out var nestedMessage))
|
||||
if (messageOrRoot.SafeTryGetProperty("message", out var nestedMessage))
|
||||
message = nestedMessage;
|
||||
|
||||
var consumed = false;
|
||||
if (message.TryGetProperty("content", out var contentEl) &&
|
||||
if (message.SafeTryGetProperty("content", out var contentEl) &&
|
||||
contentEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var parsedText = contentEl.GetString();
|
||||
var parsedText = contentEl.SafeGetString();
|
||||
if (!string.IsNullOrWhiteSpace(parsedText))
|
||||
{
|
||||
text = parsedText;
|
||||
@@ -1374,10 +1376,10 @@ public partial class LlmService
|
||||
}
|
||||
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
|
||||
if (!consumed &&
|
||||
message.TryGetProperty("reasoning_content", out var reasoningContentEl) &&
|
||||
message.SafeTryGetProperty("reasoning_content", out var reasoningContentEl) &&
|
||||
reasoningContentEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var reasoningText = reasoningContentEl.GetString();
|
||||
var reasoningText = reasoningContentEl.SafeGetString();
|
||||
if (!string.IsNullOrWhiteSpace(reasoningText))
|
||||
{
|
||||
text = reasoningText;
|
||||
@@ -1385,22 +1387,22 @@ public partial class LlmService
|
||||
}
|
||||
}
|
||||
|
||||
if (message.TryGetProperty("tool_calls", out var toolCallsEl) &&
|
||||
if (message.SafeTryGetProperty("tool_calls", out var toolCallsEl) &&
|
||||
toolCallsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var tc in toolCallsEl.EnumerateArray())
|
||||
{
|
||||
if (!tc.TryGetProperty("function", out var functionEl))
|
||||
if (!tc.SafeTryGetProperty("function", out var functionEl))
|
||||
continue;
|
||||
|
||||
JsonElement? parsedArgs = null;
|
||||
if (functionEl.TryGetProperty("arguments", out var argsEl))
|
||||
if (functionEl.SafeTryGetProperty("arguments", out var argsEl))
|
||||
{
|
||||
if (argsEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}");
|
||||
using var argsDoc = JsonDocument.Parse(argsEl.SafeGetString() ?? "{}");
|
||||
parsedArgs = argsDoc.RootElement.Clone();
|
||||
}
|
||||
catch { parsedArgs = null; }
|
||||
@@ -1414,8 +1416,8 @@ public partial class LlmService
|
||||
toolBlocks.Add(new ContentBlock
|
||||
{
|
||||
Type = "tool_use",
|
||||
ToolName = functionEl.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "" : "",
|
||||
ToolId = tc.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12],
|
||||
ToolName = functionEl.SafeTryGetProperty("name", out var nameEl) ? nameEl.SafeGetString() ?? "" : "",
|
||||
ToolId = tc.SafeTryGetProperty("id", out var idEl) ? idEl.SafeGetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12],
|
||||
ToolInput = parsedArgs,
|
||||
});
|
||||
}
|
||||
@@ -1542,12 +1544,12 @@ public partial class LlmService
|
||||
{
|
||||
using var doc = JsonDocument.Parse(errBody);
|
||||
// Ollama: {"error":"..."}
|
||||
if (doc.RootElement.TryGetProperty("error", out var err))
|
||||
if (doc.RootElement.SafeTryGetProperty("error", out var err))
|
||||
{
|
||||
if (err.ValueKind == JsonValueKind.String)
|
||||
return err.GetString() ?? errBody;
|
||||
if (err.ValueKind == JsonValueKind.Object && err.TryGetProperty("message", out var m))
|
||||
return m.GetString() ?? errBody;
|
||||
return err.SafeGetString() ?? errBody;
|
||||
if (err.ValueKind == JsonValueKind.Object && err.SafeTryGetProperty("message", out var m))
|
||||
return m.SafeGetString() ?? errBody;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
@@ -403,17 +404,17 @@ public partial class LlmService : IDisposable
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(m.Content);
|
||||
if (doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocks))
|
||||
if (doc.RootElement.SafeTryGetProperty("_tool_use_blocks", out var blocks))
|
||||
{
|
||||
var parts = new List<string>();
|
||||
foreach (var block in blocks.EnumerateArray())
|
||||
{
|
||||
if (!block.TryGetProperty("type", out var typeEl)) continue;
|
||||
var type = typeEl.GetString();
|
||||
if (type == "text" && block.TryGetProperty("text", out var textEl))
|
||||
parts.Add(textEl.GetString() ?? "");
|
||||
else if (type == "tool_use" && block.TryGetProperty("name", out var nameEl))
|
||||
parts.Add($"[도구 호출: {nameEl.GetString()}]");
|
||||
if (!block.SafeTryGetProperty("type", out var typeEl)) continue;
|
||||
var type = typeEl.SafeGetString();
|
||||
if (type == "text" && block.SafeTryGetProperty("text", out var textEl))
|
||||
parts.Add(textEl.SafeGetString() ?? "");
|
||||
else if (type == "tool_use" && block.SafeTryGetProperty("name", out var nameEl))
|
||||
parts.Add($"[도구 호출: {nameEl.SafeGetString()}]");
|
||||
}
|
||||
var content = string.Join("\n", parts).Trim();
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
@@ -431,8 +432,8 @@ public partial class LlmService : IDisposable
|
||||
{
|
||||
using var doc = JsonDocument.Parse(m.Content);
|
||||
var root = doc.RootElement;
|
||||
var toolName = root.TryGetProperty("tool_name", out var tn) ? tn.GetString() ?? "tool" : "tool";
|
||||
var toolContent = root.TryGetProperty("content", out var tc) ? tc.GetString() ?? "" : "";
|
||||
var toolName = root.SafeTryGetProperty("tool_name", out var tn) ? tn.SafeGetString() ?? "tool" : "tool";
|
||||
var toolContent = root.SafeTryGetProperty("content", out var tc) ? tc.SafeGetString() ?? "" : "";
|
||||
msgs.Add(new { role = "user", content = $"[{toolName} 결과]\n{toolContent}" });
|
||||
continue;
|
||||
}
|
||||
@@ -461,41 +462,41 @@ public partial class LlmService : IDisposable
|
||||
|
||||
private static string ExtractIbmDeploymentText(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0)
|
||||
if (root.SafeTryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0)
|
||||
{
|
||||
var message = choices[0].TryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
|
||||
var message = choices[0].SafeTryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
|
||||
if (message.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (message.TryGetProperty("content", out var content))
|
||||
if (message.SafeTryGetProperty("content", out var content))
|
||||
{
|
||||
var text = content.GetString();
|
||||
var text = content.SafeGetString();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
return text;
|
||||
}
|
||||
// Qwen3.5 thinking 모드 폴백
|
||||
if (message.TryGetProperty("reasoning_content", out var reasoning))
|
||||
if (message.SafeTryGetProperty("reasoning_content", out var reasoning))
|
||||
{
|
||||
var text = reasoning.GetString();
|
||||
var text = reasoning.SafeGetString();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
|
||||
if (root.SafeTryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
|
||||
{
|
||||
var first = results[0];
|
||||
if (first.TryGetProperty("generated_text", out var generatedText))
|
||||
return generatedText.GetString() ?? "";
|
||||
if (first.TryGetProperty("output_text", out var outputText))
|
||||
return outputText.GetString() ?? "";
|
||||
if (first.SafeTryGetProperty("generated_text", out var generatedText))
|
||||
return generatedText.SafeGetString() ?? "";
|
||||
if (first.SafeTryGetProperty("output_text", out var outputText))
|
||||
return outputText.SafeGetString() ?? "";
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("generated_text", out var generated))
|
||||
return generated.GetString() ?? "";
|
||||
if (root.SafeTryGetProperty("generated_text", out var generated))
|
||||
return generated.SafeGetString() ?? "";
|
||||
|
||||
if (root.TryGetProperty("message", out var messageValue) && messageValue.ValueKind == JsonValueKind.String)
|
||||
return messageValue.GetString() ?? "";
|
||||
if (root.SafeTryGetProperty("message", out var messageValue) && messageValue.ValueKind == JsonValueKind.String)
|
||||
return messageValue.SafeGetString() ?? "";
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -719,7 +720,10 @@ public partial class LlmService : IDisposable
|
||||
return SafeParseJson(resp, root =>
|
||||
{
|
||||
TryParseOllamaUsage(root);
|
||||
return root.GetProperty("message").GetProperty("content").GetString() ?? "";
|
||||
var msg = root.SafeGetProperty("message");
|
||||
if (msg == null) return root.SafeGetString() ?? "(빈 응답)";
|
||||
if (msg.Value.ValueKind == JsonValueKind.String) return msg.Value.SafeGetString() ?? "";
|
||||
return msg.Value.SafeGetProperty("content")?.SafeGetString() ?? "";
|
||||
}, "Ollama 응답");
|
||||
}
|
||||
|
||||
@@ -759,10 +763,10 @@ public partial class LlmService : IDisposable
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
if (doc.RootElement.TryGetProperty("message", out var msg) &&
|
||||
msg.TryGetProperty("content", out var c))
|
||||
text = c.GetString();
|
||||
if (doc.RootElement.TryGetProperty("done", out var done) && done.GetBoolean())
|
||||
if (doc.RootElement.SafeTryGetProperty("message", out var msg) &&
|
||||
msg.SafeTryGetProperty("content", out var c))
|
||||
text = c.SafeGetString();
|
||||
if (doc.RootElement.SafeTryGetProperty("done", out var done) && done.GetBoolean())
|
||||
TryParseOllamaUsage(doc.RootElement);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
@@ -827,9 +831,15 @@ public partial class LlmService : IDisposable
|
||||
return string.IsNullOrWhiteSpace(parsed) ? "(빈 응답)" : parsed;
|
||||
}
|
||||
|
||||
var choices = root.GetProperty("choices");
|
||||
if (choices.GetArrayLength() == 0) return "(빈 응답)";
|
||||
return choices[0].GetProperty("message").GetProperty("content").GetString() ?? "";
|
||||
if (!root.SafeTryGetProperty("choices", out var choices)
|
||||
|| choices.ValueKind != JsonValueKind.Array
|
||||
|| choices.GetArrayLength() == 0)
|
||||
return "(빈 응답)";
|
||||
var firstChoice = choices[0];
|
||||
var msg = firstChoice.SafeGetProperty("message");
|
||||
if (msg == null) return firstChoice.SafeGetString() ?? "(빈 응답)";
|
||||
if (msg.Value.ValueKind == JsonValueKind.String) return msg.Value.SafeGetString() ?? "";
|
||||
return msg.Value.SafeGetProperty("content")?.SafeGetString() ?? "";
|
||||
}, "vLLM 응답");
|
||||
}
|
||||
|
||||
@@ -867,27 +877,27 @@ public partial class LlmService : IDisposable
|
||||
{
|
||||
using var doc = JsonDocument.Parse(data);
|
||||
// 스트리밍 청크(delta) → content 누적
|
||||
if (doc.RootElement.TryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0)
|
||||
if (doc.RootElement.SafeTryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0)
|
||||
{
|
||||
var first = ch[0];
|
||||
if (first.TryGetProperty("delta", out var delta))
|
||||
if (first.SafeTryGetProperty("delta", out var delta))
|
||||
{
|
||||
string? txt = null;
|
||||
if (delta.TryGetProperty("content", out var cnt))
|
||||
txt = cnt.GetString();
|
||||
if (delta.SafeTryGetProperty("content", out var cnt))
|
||||
txt = cnt.SafeGetString();
|
||||
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
|
||||
if (string.IsNullOrEmpty(txt) && delta.TryGetProperty("reasoning_content", out var rc))
|
||||
txt = rc.GetString();
|
||||
if (string.IsNullOrEmpty(txt) && delta.SafeTryGetProperty("reasoning_content", out var rc))
|
||||
txt = rc.SafeGetString();
|
||||
if (!string.IsNullOrEmpty(txt)) { sb.Append(txt); collectingChunks = true; }
|
||||
}
|
||||
else if (first.TryGetProperty("message", out _))
|
||||
else if (first.SafeTryGetProperty("message", out _))
|
||||
{
|
||||
// 완성 응답 → 이 JSON을 그대로 사용
|
||||
return data;
|
||||
}
|
||||
}
|
||||
// IBM results[] 형식
|
||||
else if (doc.RootElement.TryGetProperty("results", out var res) && res.GetArrayLength() > 0)
|
||||
else if (doc.RootElement.SafeTryGetProperty("results", out var res) && res.GetArrayLength() > 0)
|
||||
{
|
||||
return data;
|
||||
}
|
||||
@@ -960,44 +970,67 @@ public partial class LlmService : IDisposable
|
||||
TryParseOpenAiUsage(doc.RootElement);
|
||||
if (usesIbmDeploymentApi)
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty("status", out var status) &&
|
||||
string.Equals(status.GetString(), "error", StringComparison.OrdinalIgnoreCase))
|
||||
if (doc.RootElement.SafeTryGetProperty("status", out var status) &&
|
||||
string.Equals(status.SafeGetString(), "error", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var detail = doc.RootElement.TryGetProperty("message", out var message)
|
||||
? message.GetString()
|
||||
var detail = doc.RootElement.SafeTryGetProperty("message", out var message)
|
||||
? message.SafeGetString()
|
||||
: "IBM vLLM 스트리밍 오류";
|
||||
throw new InvalidOperationException(detail);
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("results", out var results) &&
|
||||
if (doc.RootElement.SafeTryGetProperty("results", out var results) &&
|
||||
results.ValueKind == JsonValueKind.Array &&
|
||||
results.GetArrayLength() > 0)
|
||||
{
|
||||
var first = results[0];
|
||||
if (first.TryGetProperty("generated_text", out var generatedText))
|
||||
text = generatedText.GetString();
|
||||
else if (first.TryGetProperty("output_text", out var outputText))
|
||||
text = outputText.GetString();
|
||||
if (first.SafeTryGetProperty("generated_text", out var generatedText))
|
||||
text = generatedText.SafeGetString();
|
||||
else if (first.SafeTryGetProperty("output_text", out var outputText))
|
||||
text = outputText.SafeGetString();
|
||||
}
|
||||
else if (doc.RootElement.TryGetProperty("choices", out var ibmChoices) && ibmChoices.GetArrayLength() > 0)
|
||||
else if (doc.RootElement.SafeTryGetProperty("choices", out var ibmChoices)
|
||||
&& ibmChoices.ValueKind == JsonValueKind.Array
|
||||
&& ibmChoices.GetArrayLength() > 0)
|
||||
{
|
||||
var delta = ibmChoices[0].GetProperty("delta");
|
||||
if (delta.TryGetProperty("content", out var c))
|
||||
text = c.GetString();
|
||||
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc))
|
||||
text = rc.GetString();
|
||||
var fc = ibmChoices[0];
|
||||
if (fc.SafeTryGetProperty("delta", out var delta))
|
||||
{
|
||||
if (delta.ValueKind == JsonValueKind.String)
|
||||
text = delta.SafeGetString();
|
||||
else
|
||||
{
|
||||
if (delta.SafeTryGetProperty("content", out var c))
|
||||
text = c.SafeGetString();
|
||||
if (string.IsNullOrEmpty(text) && delta.SafeTryGetProperty("reasoning_content", out var rc))
|
||||
text = rc.SafeGetString();
|
||||
}
|
||||
}
|
||||
else if (fc.ValueKind == JsonValueKind.String)
|
||||
text = fc.SafeGetString();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var choices = doc.RootElement.GetProperty("choices");
|
||||
if (choices.GetArrayLength() > 0)
|
||||
if (doc.RootElement.SafeTryGetProperty("choices", out var choices)
|
||||
&& choices.ValueKind == JsonValueKind.Array
|
||||
&& choices.GetArrayLength() > 0)
|
||||
{
|
||||
var delta = choices[0].GetProperty("delta");
|
||||
if (delta.TryGetProperty("content", out var c))
|
||||
text = c.GetString();
|
||||
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc2))
|
||||
text = rc2.GetString();
|
||||
var fc = choices[0];
|
||||
if (fc.SafeTryGetProperty("delta", out var delta))
|
||||
{
|
||||
if (delta.ValueKind == JsonValueKind.String)
|
||||
text = delta.SafeGetString();
|
||||
else
|
||||
{
|
||||
if (delta.SafeTryGetProperty("content", out var c))
|
||||
text = c.SafeGetString();
|
||||
if (string.IsNullOrEmpty(text) && delta.SafeTryGetProperty("reasoning_content", out var rc2))
|
||||
text = rc2.SafeGetString();
|
||||
}
|
||||
}
|
||||
else if (fc.ValueKind == JsonValueKind.String)
|
||||
text = fc.SafeGetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1021,6 +1054,9 @@ public partial class LlmService : IDisposable
|
||||
["temperature"] = ResolveTemperature(),
|
||||
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens()
|
||||
};
|
||||
// 스트리밍 시 마지막 청크에 토큰 사용량을 포함하도록 요청 (vLLM/OpenAI 호환)
|
||||
if (stream)
|
||||
body["stream_options"] = new { include_usage = true };
|
||||
var effort = ResolveReasoningEffort();
|
||||
if (!string.IsNullOrWhiteSpace(effort))
|
||||
body["reasoning_effort"] = effort;
|
||||
@@ -1045,11 +1081,16 @@ public partial class LlmService : IDisposable
|
||||
return SafeParseJson(resp, root =>
|
||||
{
|
||||
TryParseGeminiUsage(root);
|
||||
var candidates = root.GetProperty("candidates");
|
||||
if (candidates.GetArrayLength() == 0) return "(빈 응답)";
|
||||
var parts = candidates[0].GetProperty("content").GetProperty("parts");
|
||||
if (parts.GetArrayLength() == 0) return "(빈 응답)";
|
||||
return parts[0].GetProperty("text").GetString() ?? "";
|
||||
if (!root.SafeTryGetProperty("candidates", out var candidates)
|
||||
|| candidates.ValueKind != JsonValueKind.Array
|
||||
|| candidates.GetArrayLength() == 0)
|
||||
return "(빈 응답)";
|
||||
var first = candidates[0];
|
||||
var content = first.SafeGetProperty("content");
|
||||
if (content == null || !content.Value.SafeTryGetProperty("parts", out var parts)
|
||||
|| parts.ValueKind != JsonValueKind.Array || parts.GetArrayLength() == 0)
|
||||
return first.SafeGetString() ?? "(빈 응답)";
|
||||
return parts[0].SafeGetProperty("text")?.SafeGetString() ?? "";
|
||||
}, "Gemini 응답");
|
||||
}
|
||||
|
||||
@@ -1093,15 +1134,19 @@ public partial class LlmService : IDisposable
|
||||
{
|
||||
using var doc = JsonDocument.Parse(data);
|
||||
TryParseGeminiUsage(doc.RootElement);
|
||||
var candidates = doc.RootElement.GetProperty("candidates");
|
||||
if (candidates.GetArrayLength() == 0) continue;
|
||||
if (!doc.RootElement.SafeTryGetProperty("candidates", out var candidates)
|
||||
|| candidates.ValueKind != JsonValueKind.Array
|
||||
|| candidates.GetArrayLength() == 0) continue;
|
||||
var sb = new StringBuilder();
|
||||
var parts = candidates[0].GetProperty("content").GetProperty("parts");
|
||||
var firstCand = candidates[0];
|
||||
var contentEl = firstCand.SafeGetProperty("content");
|
||||
if (contentEl == null || !contentEl.Value.SafeTryGetProperty("parts", out var parts)
|
||||
|| parts.ValueKind != JsonValueKind.Array) continue;
|
||||
foreach (var part in parts.EnumerateArray())
|
||||
{
|
||||
if (part.TryGetProperty("text", out var t))
|
||||
if (part.SafeTryGetProperty("text", out var t))
|
||||
{
|
||||
var text = t.GetString();
|
||||
var text = t.SafeGetString();
|
||||
if (!string.IsNullOrEmpty(text)) sb.Append(text);
|
||||
}
|
||||
}
|
||||
@@ -1185,9 +1230,11 @@ public partial class LlmService : IDisposable
|
||||
return SafeParseJson(respJson, root =>
|
||||
{
|
||||
TryParseSigmoidUsage(root);
|
||||
var content = root.GetProperty("content");
|
||||
if (content.GetArrayLength() == 0) return "(빈 응답)";
|
||||
return content[0].GetProperty("text").GetString() ?? "";
|
||||
if (!root.SafeTryGetProperty("content", out var content)
|
||||
|| content.ValueKind != JsonValueKind.Array
|
||||
|| content.GetArrayLength() == 0)
|
||||
return root.SafeGetString() ?? "(빈 응답)";
|
||||
return content[0].SafeGetProperty("text")?.SafeGetString() ?? "";
|
||||
}, "Claude 응답");
|
||||
}
|
||||
|
||||
@@ -1237,20 +1284,20 @@ public partial class LlmService : IDisposable
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(data);
|
||||
var type = doc.RootElement.GetProperty("type").GetString();
|
||||
var type = doc.RootElement.SafeGetProperty("type")?.SafeGetString();
|
||||
if (type == "content_block_delta")
|
||||
{
|
||||
var delta = doc.RootElement.GetProperty("delta");
|
||||
if (delta.TryGetProperty("text", out var t))
|
||||
text = t.GetString();
|
||||
if (!doc.RootElement.SafeTryGetProperty("delta", out var delta)) continue;
|
||||
if (delta.SafeTryGetProperty("text", out var t))
|
||||
text = t.SafeGetString();
|
||||
}
|
||||
else if (type is "message_start" or "message_delta")
|
||||
{
|
||||
// message_start: usage in .message.usage, message_delta: usage in .usage
|
||||
if (doc.RootElement.TryGetProperty("message", out var msg) &&
|
||||
msg.TryGetProperty("usage", out var u1))
|
||||
if (doc.RootElement.SafeTryGetProperty("message", out var msg) &&
|
||||
msg.SafeTryGetProperty("usage", out var u1))
|
||||
TryParseSigmoidUsageFromElement(u1);
|
||||
else if (doc.RootElement.TryGetProperty("usage", out var u2))
|
||||
else if (doc.RootElement.SafeTryGetProperty("usage", out var u2))
|
||||
TryParseSigmoidUsageFromElement(u2);
|
||||
}
|
||||
}
|
||||
@@ -1434,9 +1481,9 @@ public partial class LlmService : IDisposable
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
// API 에러 응답 감지
|
||||
if (doc.RootElement.TryGetProperty("error", out var error))
|
||||
if (doc.RootElement.SafeTryGetProperty("error", out var error))
|
||||
{
|
||||
var msg = error.TryGetProperty("message", out var m) ? m.GetString() : error.ToString();
|
||||
var msg = error.SafeTryGetProperty("message", out var m) ? m.SafeGetString() : error.ToString();
|
||||
throw new HttpRequestException($"[{context}] API 에러: {msg}");
|
||||
}
|
||||
|
||||
@@ -1468,12 +1515,12 @@ public partial class LlmService : IDisposable
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("error", out var err))
|
||||
if (doc.RootElement.SafeTryGetProperty("error", out var err))
|
||||
{
|
||||
if (err.ValueKind == JsonValueKind.Object && err.TryGetProperty("message", out var m))
|
||||
detail = m.GetString() ?? "";
|
||||
if (err.ValueKind == JsonValueKind.Object && err.SafeTryGetProperty("message", out var m))
|
||||
detail = m.SafeGetString() ?? "";
|
||||
else if (err.ValueKind == JsonValueKind.String)
|
||||
detail = err.GetString() ?? "";
|
||||
detail = err.SafeGetString() ?? "";
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
@@ -1506,8 +1553,8 @@ public partial class LlmService : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var prompt = root.TryGetProperty("prompt_eval_count", out var p) ? p.GetInt32() : 0;
|
||||
var completion = root.TryGetProperty("eval_count", out var e) ? e.GetInt32() : 0;
|
||||
var prompt = root.SafeTryGetProperty("prompt_eval_count", out var p) ? p.GetInt32() : 0;
|
||||
var completion = root.SafeTryGetProperty("eval_count", out var e) ? e.GetInt32() : 0;
|
||||
if (prompt > 0 || completion > 0)
|
||||
LastTokenUsage = new TokenUsage(prompt, completion);
|
||||
}
|
||||
@@ -1518,9 +1565,9 @@ public partial class LlmService : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!root.TryGetProperty("usage", out var usage)) return;
|
||||
var prompt = usage.TryGetProperty("prompt_tokens", out var p) ? p.GetInt32() : 0;
|
||||
var completion = usage.TryGetProperty("completion_tokens", out var c) ? c.GetInt32() : 0;
|
||||
if (!root.SafeTryGetProperty("usage", out var usage)) return;
|
||||
var prompt = usage.SafeTryGetProperty("prompt_tokens", out var p) ? p.SafeGetInt32(0) : 0;
|
||||
var completion = usage.SafeTryGetProperty("completion_tokens", out var c) ? c.SafeGetInt32(0) : 0;
|
||||
if (prompt > 0 || completion > 0)
|
||||
LastTokenUsage = new TokenUsage(prompt, completion);
|
||||
}
|
||||
@@ -1531,9 +1578,9 @@ public partial class LlmService : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!root.TryGetProperty("usageMetadata", out var usage)) return;
|
||||
var prompt = usage.TryGetProperty("promptTokenCount", out var p) ? p.GetInt32() : 0;
|
||||
var completion = usage.TryGetProperty("candidatesTokenCount", out var c) ? c.GetInt32() : 0;
|
||||
if (!root.SafeTryGetProperty("usageMetadata", out var usage)) return;
|
||||
var prompt = usage.SafeTryGetProperty("promptTokenCount", out var p) ? p.GetInt32() : 0;
|
||||
var completion = usage.SafeTryGetProperty("candidatesTokenCount", out var c) ? c.GetInt32() : 0;
|
||||
if (prompt > 0 || completion > 0)
|
||||
LastTokenUsage = new TokenUsage(prompt, completion);
|
||||
}
|
||||
@@ -1544,7 +1591,7 @@ public partial class LlmService : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!root.TryGetProperty("usage", out var usage)) return;
|
||||
if (!root.SafeTryGetProperty("usage", out var usage)) return;
|
||||
TryParseSigmoidUsageFromElement(usage);
|
||||
}
|
||||
catch { }
|
||||
@@ -1554,8 +1601,8 @@ public partial class LlmService : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
var input = usage.TryGetProperty("input_tokens", out var i) ? i.GetInt32() : 0;
|
||||
var output = usage.TryGetProperty("output_tokens", out var o) ? o.GetInt32() : 0;
|
||||
var input = usage.SafeTryGetProperty("input_tokens", out var i) ? i.GetInt32() : 0;
|
||||
var output = usage.SafeTryGetProperty("output_tokens", out var o) ? o.GetInt32() : 0;
|
||||
if (input > 0 || output > 0)
|
||||
LastTokenUsage = new TokenUsage(input, output);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services.Agent;
|
||||
|
||||
namespace AxCopilot.Services;
|
||||
|
||||
@@ -114,34 +115,34 @@ public class McpClientService : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
if (result.Value.TryGetProperty("tools", out var toolsArr))
|
||||
if (result.Value.SafeTryGetProperty("tools", out var toolsArr))
|
||||
{
|
||||
foreach (var tool in toolsArr.EnumerateArray())
|
||||
{
|
||||
var def = new McpToolDefinition
|
||||
{
|
||||
Name = tool.GetProperty("name").GetString() ?? "",
|
||||
Description = tool.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "",
|
||||
Name = tool.GetProperty("name").SafeGetString() ?? "",
|
||||
Description = tool.SafeTryGetProperty("description", out var desc) ? desc.SafeGetString() ?? "" : "",
|
||||
ServerName = _config.Name,
|
||||
};
|
||||
|
||||
if (tool.TryGetProperty("inputSchema", out var schema) &&
|
||||
schema.TryGetProperty("properties", out var props))
|
||||
if (tool.SafeTryGetProperty("inputSchema", out var schema) &&
|
||||
schema.SafeTryGetProperty("properties", out var props))
|
||||
{
|
||||
foreach (var prop in props.EnumerateObject())
|
||||
{
|
||||
def.Parameters[prop.Name] = new McpParameterDef
|
||||
{
|
||||
Type = prop.Value.TryGetProperty("type", out var t) ? t.GetString() ?? "string" : "string",
|
||||
Description = prop.Value.TryGetProperty("description", out var d) ? d.GetString() ?? "" : "",
|
||||
Type = prop.Value.SafeTryGetProperty("type", out var t) ? t.SafeGetString() ?? "string" : "string",
|
||||
Description = prop.Value.SafeTryGetProperty("description", out var d) ? d.SafeGetString() ?? "" : "",
|
||||
};
|
||||
}
|
||||
|
||||
if (schema.TryGetProperty("required", out var reqArr))
|
||||
if (schema.SafeTryGetProperty("required", out var reqArr))
|
||||
{
|
||||
foreach (var req in reqArr.EnumerateArray())
|
||||
{
|
||||
var reqName = req.GetString();
|
||||
var reqName = req.SafeGetString();
|
||||
if (reqName != null && def.Parameters.TryGetValue(reqName, out var p))
|
||||
p.Required = true;
|
||||
}
|
||||
@@ -167,16 +168,16 @@ public class McpClientService : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
if (result.Value.TryGetProperty("resources", out var resourcesArr))
|
||||
if (result.Value.SafeTryGetProperty("resources", out var resourcesArr))
|
||||
{
|
||||
foreach (var resource in resourcesArr.EnumerateArray())
|
||||
{
|
||||
_resources.Add(new McpResourceDefinition
|
||||
{
|
||||
Uri = resource.TryGetProperty("uri", out var uri) ? uri.GetString() ?? "" : "",
|
||||
Name = resource.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
|
||||
Description = resource.TryGetProperty("description", out var desc) ? desc.GetString() ?? "" : "",
|
||||
MimeType = resource.TryGetProperty("mimeType", out var mime) ? mime.GetString() ?? "" : "",
|
||||
Uri = resource.SafeTryGetProperty("uri", out var uri) ? uri.SafeGetString() ?? "" : "",
|
||||
Name = resource.SafeTryGetProperty("name", out var name) ? name.SafeGetString() ?? "" : "",
|
||||
Description = resource.SafeTryGetProperty("description", out var desc) ? desc.SafeGetString() ?? "" : "",
|
||||
MimeType = resource.SafeTryGetProperty("mimeType", out var mime) ? mime.SafeGetString() ?? "" : "",
|
||||
ServerName = _config.Name,
|
||||
});
|
||||
}
|
||||
@@ -201,18 +202,18 @@ public class McpClientService : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
if (result.Value.TryGetProperty("content", out var contentArr))
|
||||
if (result.Value.SafeTryGetProperty("content", out var contentArr))
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var item in contentArr.EnumerateArray())
|
||||
{
|
||||
if (item.TryGetProperty("text", out var text))
|
||||
sb.AppendLine(text.GetString());
|
||||
if (item.SafeTryGetProperty("text", out var text))
|
||||
sb.AppendLine(text.SafeGetString());
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
if (result.Value.TryGetProperty("isError", out var isErr) && isErr.GetBoolean())
|
||||
if (result.Value.SafeTryGetProperty("isError", out var isErr) && isErr.GetBoolean())
|
||||
{
|
||||
return $"[MCP 오류] {result}";
|
||||
}
|
||||
@@ -235,15 +236,15 @@ public class McpClientService : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
if (result.Value.TryGetProperty("contents", out var contentsArr))
|
||||
if (result.Value.SafeTryGetProperty("contents", out var contentsArr))
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var item in contentsArr.EnumerateArray())
|
||||
{
|
||||
if (item.TryGetProperty("text", out var text))
|
||||
sb.AppendLine(text.GetString());
|
||||
else if (item.TryGetProperty("uri", out var itemUri))
|
||||
sb.AppendLine($"uri: {itemUri.GetString()}");
|
||||
if (item.SafeTryGetProperty("text", out var text))
|
||||
sb.AppendLine(text.SafeGetString());
|
||||
else if (item.SafeTryGetProperty("uri", out var itemUri))
|
||||
sb.AppendLine($"uri: {itemUri.SafeGetString()}");
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
@@ -285,14 +286,14 @@ public class McpClientService : IDisposable
|
||||
var root = doc.RootElement;
|
||||
|
||||
// notification은 건너뛰기
|
||||
if (!root.TryGetProperty("id", out _)) continue;
|
||||
if (!root.SafeTryGetProperty("id", out _)) continue;
|
||||
|
||||
if (root.TryGetProperty("result", out var result))
|
||||
if (root.SafeTryGetProperty("result", out var result))
|
||||
return result;
|
||||
|
||||
if (root.TryGetProperty("error", out var error))
|
||||
if (root.SafeTryGetProperty("error", out var error))
|
||||
{
|
||||
var msg = error.TryGetProperty("message", out var m) ? m.GetString() : "Unknown error";
|
||||
var msg = error.SafeTryGetProperty("message", out var m) ? m.SafeGetString() : "Unknown error";
|
||||
LogService.Warn($"MCP '{_config.Name}' RPC 오류: {msg}");
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -42,10 +42,30 @@ public partial class ChatWindow
|
||||
|
||||
/// <summary>
|
||||
/// 에이전트 이벤트를 백그라운드 큐에 추가합니다.
|
||||
/// 대화 변이(AppendExecutionEvent, AppendAgentRun)와 디스크 저장을 백그라운드에서 처리합니다.
|
||||
/// ExecutionEvents는 UI 스레드에서 즉시 추가 (타임라인 렌더링 누락 방지).
|
||||
/// 디스크 저장과 AgentRun 기록은 백그라운드에서 처리합니다.
|
||||
/// </summary>
|
||||
private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender)
|
||||
{
|
||||
// ── 즉시 추가: ExecutionEvents에 동기적으로 반영 (RenderMessages 누락 방지) ──
|
||||
try
|
||||
{
|
||||
lock (_convLock)
|
||||
{
|
||||
var session = _appState.ChatSession;
|
||||
if (session != null)
|
||||
{
|
||||
var result = _chatEngine.AppendExecutionEvent(
|
||||
session, null!, _currentConversation, _activeTab, eventTab, evt);
|
||||
_currentConversation = result.CurrentConversation;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Debug($"UI 스레드 이벤트 즉시 추가 실패: {ex.Message}");
|
||||
}
|
||||
|
||||
_agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender));
|
||||
}
|
||||
|
||||
|
||||
@@ -181,9 +181,9 @@ public partial class ChatWindow
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
var stack = new StackPanel
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
MaxWidth = msgMaxWidth,
|
||||
Margin = new Thickness(0),
|
||||
Margin = new Thickness(48, 1, 12, 1),
|
||||
};
|
||||
|
||||
var liveWaitingStyle = evt.Type == AgentEventType.Thinking
|
||||
@@ -400,13 +400,12 @@ public partial class ChatWindow
|
||||
|
||||
return new Border
|
||||
{
|
||||
Background = cardBackground,
|
||||
BorderBrush = cardBorder,
|
||||
BorderThickness = new Thickness(1),
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(10, 8, 10, 8),
|
||||
Margin = new Thickness(12, 6, 12, 2),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
Padding = new Thickness(0, 4, 0, 4),
|
||||
Margin = new Thickness(0, 1, 12, 1),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Child = contentGrid,
|
||||
};
|
||||
}
|
||||
@@ -1186,6 +1185,30 @@ public partial class ChatWindow
|
||||
};
|
||||
}
|
||||
|
||||
// HTML/대용량 파일 내용이 이벤트 요약에 포함된 경우 인라인 표시 대신 1줄 요약으로 축소
|
||||
if (!string.IsNullOrWhiteSpace(eventSummaryText) && evt.Type == AgentEventType.ToolResult
|
||||
&& eventSummaryText.Length > 500)
|
||||
{
|
||||
// HTML 내용 감지: <!DOCTYPE, <html, <head, <body, <div 등
|
||||
var trimmed = eventSummaryText.TrimStart();
|
||||
var firstLine = trimmed.Split('\n')[0];
|
||||
if (trimmed.Contains("<!DOCTYPE", StringComparison.OrdinalIgnoreCase)
|
||||
|| trimmed.Contains("<html", StringComparison.OrdinalIgnoreCase)
|
||||
|| (evt.FilePath?.EndsWith(".html", StringComparison.OrdinalIgnoreCase) == true)
|
||||
|| (evt.FilePath?.EndsWith(".htm", StringComparison.OrdinalIgnoreCase) == true))
|
||||
{
|
||||
var lineCount = eventSummaryText.Count(c => c == '\n') + 1;
|
||||
eventSummaryText = $"{firstLine}\n({lineCount}줄 · HTML 문서 — 미리보기 패널에서 확인)";
|
||||
}
|
||||
else if (eventSummaryText.Length > 2000)
|
||||
{
|
||||
// 기타 대용량 텍스트: 첫 3줄만 표시
|
||||
var lines = eventSummaryText.Split('\n');
|
||||
var preview = string.Join("\n", lines.Take(3));
|
||||
eventSummaryText = preview + $"\n... ({lines.Length}줄 전체)";
|
||||
}
|
||||
}
|
||||
|
||||
if (evt.Type == AgentEventType.StepStart && evt.StepTotal > 0)
|
||||
UpdateProgressBar(evt);
|
||||
|
||||
@@ -1204,13 +1227,12 @@ public partial class ChatWindow
|
||||
var bannerMaxWidth = GetMessageMaxWidth();
|
||||
var banner = new Border
|
||||
{
|
||||
Background = hintBg,
|
||||
BorderBrush = borderColor,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(9, 7, 9, 7),
|
||||
Margin = new Thickness(12, 3, 12, 3),
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(14, 5, 14, 5),
|
||||
Margin = new Thickness(48, 1, 12, 1),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
MaxWidth = bannerMaxWidth,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(evt.RunId))
|
||||
|
||||
@@ -33,12 +33,13 @@ public partial class ChatWindow
|
||||
var triggerRatio = triggerPercent / 100.0;
|
||||
|
||||
// 메시지 토큰 추정: 메시지 수나 대화 ID가 바뀔 때만 재계산 (타이핑 중 반복 계산 방지)
|
||||
// 스트리밍 중에는 매번 재계산 (도구 결과 메시지가 실시간으로 추가됨)
|
||||
int messageTokens;
|
||||
lock (_convLock)
|
||||
{
|
||||
var convId = _currentConversation?.Id;
|
||||
var msgCount = _currentConversation?.Messages?.Count ?? 0;
|
||||
if (convId != _cachedConvIdForTokens || msgCount != _cachedMessageCountForTokens)
|
||||
if (convId != _cachedConvIdForTokens || msgCount != _cachedMessageCountForTokens || _isStreaming)
|
||||
{
|
||||
_cachedMessageTokens = msgCount > 0
|
||||
? Services.TokenEstimator.EstimateMessages(_currentConversation!.Messages)
|
||||
|
||||
@@ -16,6 +16,165 @@ public partial class ChatWindow
|
||||
private const int ConversationPageSize = 50;
|
||||
private List<ConversationMeta>? _pendingConversations;
|
||||
|
||||
// ── A-1: 이벤트 위임 필드 ──
|
||||
/// <summary>현재 마우스가 올라가 있는 대화 항목 Border.</summary>
|
||||
private Border? _lastHoveredConvBorder;
|
||||
private bool _convPanelDelegationInitialized;
|
||||
|
||||
/// <summary>대화 항목 Border.Tag에 저장하는 메타 데이터.</summary>
|
||||
private sealed class ConversationItemTag
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required bool IsSelected { get; init; }
|
||||
public TextBlock? TitleBlock { get; init; }
|
||||
public Brush? TitleColor { get; init; }
|
||||
public Button? CatButton { get; init; }
|
||||
public Brush? HoverBackground { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>ConversationPanel에 이벤트 위임 핸들러를 1회 등록합니다.</summary>
|
||||
private void InitConversationPanelDelegation()
|
||||
{
|
||||
if (_convPanelDelegationInitialized || ConversationPanel == null)
|
||||
return;
|
||||
_convPanelDelegationInitialized = true;
|
||||
|
||||
ConversationPanel.MouseMove += ConversationPanel_DelegatedMouseMove;
|
||||
ConversationPanel.MouseLeave += ConversationPanel_DelegatedMouseLeave;
|
||||
ConversationPanel.PreviewMouseLeftButtonDown += ConversationPanel_DelegatedLeftButtonDown;
|
||||
ConversationPanel.PreviewMouseRightButtonUp += ConversationPanel_DelegatedRightButtonUp;
|
||||
}
|
||||
|
||||
private void ConversationPanel_DelegatedMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
|
||||
if (ReferenceEquals(border, _lastHoveredConvBorder))
|
||||
return;
|
||||
|
||||
// 이전 호버 해제
|
||||
if (_lastHoveredConvBorder?.Tag is ConversationItemTag prevTag)
|
||||
{
|
||||
if (!prevTag.IsSelected)
|
||||
_lastHoveredConvBorder.Background = Brushes.Transparent;
|
||||
if (prevTag.CatButton != null)
|
||||
prevTag.CatButton.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
_lastHoveredConvBorder = border;
|
||||
|
||||
// 새 호버 적용
|
||||
if (border?.Tag is ConversationItemTag tag)
|
||||
{
|
||||
if (!tag.IsSelected)
|
||||
border.Background = tag.HoverBackground ?? Brushes.Transparent;
|
||||
if (tag.CatButton != null)
|
||||
tag.CatButton.Visibility = Visibility.Visible;
|
||||
}
|
||||
}
|
||||
|
||||
private void ConversationPanel_DelegatedMouseLeave(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (_lastHoveredConvBorder?.Tag is ConversationItemTag prevTag)
|
||||
{
|
||||
if (!prevTag.IsSelected)
|
||||
_lastHoveredConvBorder.Background = Brushes.Transparent;
|
||||
if (prevTag.CatButton != null)
|
||||
prevTag.CatButton.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
_lastHoveredConvBorder = null;
|
||||
}
|
||||
|
||||
private void ConversationPanel_DelegatedLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
// catBtn Click은 자체 이벤트로 처리 — Button 내부 클릭이면 무시
|
||||
if (FindAncestor<Button>(e.OriginalSource as DependencyObject) is not null)
|
||||
return;
|
||||
|
||||
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
|
||||
if (border?.Tag is not ConversationItemTag tag)
|
||||
return;
|
||||
|
||||
e.Handled = true;
|
||||
HandleConversationItemClick(tag);
|
||||
}
|
||||
|
||||
private void ConversationPanel_DelegatedRightButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
var border = FindAncestorWithTag<Border>(e.OriginalSource as DependencyObject);
|
||||
if (border?.Tag is not ConversationItemTag tag)
|
||||
return;
|
||||
|
||||
e.Handled = true;
|
||||
HandleConversationItemRightClick(tag);
|
||||
}
|
||||
|
||||
private void HandleConversationItemClick(ConversationItemTag tag)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (tag.IsSelected)
|
||||
{
|
||||
if (tag.TitleBlock != null && tag.TitleColor != null)
|
||||
EnterTitleEditMode(tag.TitleBlock, tag.Id, tag.TitleColor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_streamingTabs.Contains(_activeTab))
|
||||
{
|
||||
if (_tabStreamCts.TryGetValue(_activeTab, out var convListCts)) convListCts.Cancel();
|
||||
_cursorTimer.Stop();
|
||||
_typingTimer.Stop();
|
||||
_elapsedTimer.Stop();
|
||||
_activeStreamText = null;
|
||||
_elapsedLabel = null;
|
||||
_streamingTabs.Remove(_activeTab);
|
||||
if (_tabStreamCts.Remove(_activeTab, out var removedCts)) removedCts.Dispose();
|
||||
}
|
||||
|
||||
var conv = _storage.Load(tag.Id);
|
||||
if (conv == null)
|
||||
return;
|
||||
|
||||
lock (_convLock)
|
||||
{
|
||||
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
|
||||
SyncTabConversationIdsFromSession();
|
||||
}
|
||||
|
||||
SaveLastConversations();
|
||||
UpdateChatTitle();
|
||||
RenderMessages();
|
||||
RefreshConversationList();
|
||||
RefreshDraftQueueUi();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"대화 전환 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleConversationItemRightClick(ConversationItemTag tag)
|
||||
{
|
||||
if (!tag.IsSelected)
|
||||
{
|
||||
var conv = _storage.Load(tag.Id);
|
||||
if (conv != null)
|
||||
{
|
||||
lock (_convLock)
|
||||
{
|
||||
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
|
||||
SyncTabConversationIdsFromSession();
|
||||
}
|
||||
SaveLastConversations();
|
||||
UpdateChatTitle();
|
||||
RenderMessages();
|
||||
RefreshDraftQueueUi();
|
||||
}
|
||||
}
|
||||
|
||||
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(tag.Id)), DispatcherPriority.Input);
|
||||
}
|
||||
|
||||
public void RefreshConversationList()
|
||||
{
|
||||
var metas = _storage.LoadAllMeta();
|
||||
@@ -66,20 +225,29 @@ public partial class ChatWindow
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
items = items.Where(i => string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
// LINQ 체인을 한 번의 Where로 병합하여 중간 리스트 할당 제거
|
||||
items = items.Where(i =>
|
||||
i.Pinned
|
||||
|| !string.IsNullOrWhiteSpace(i.ParentId)
|
||||
|| !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.IsNullOrWhiteSpace(i.Preview)
|
||||
|| i.AgentRunCount > 0
|
||||
|| i.FailedAgentRunCount > 0
|
||||
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase)
|
||||
string.Equals(i.Tab, _activeTab, StringComparison.OrdinalIgnoreCase)
|
||||
&& (i.Pinned
|
||||
|| !string.IsNullOrWhiteSpace(i.ParentId)
|
||||
|| !string.Equals((i.Title ?? "").Trim(), "새 대화", StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.IsNullOrWhiteSpace(i.Preview)
|
||||
|| i.AgentRunCount > 0
|
||||
|| i.FailedAgentRunCount > 0
|
||||
|| !string.Equals(i.Category, ChatCategory.General, StringComparison.OrdinalIgnoreCase))
|
||||
).ToList();
|
||||
|
||||
_failedConversationCount = items.Count(i => i.FailedAgentRunCount > 0);
|
||||
_runningConversationCount = items.Count(i => i.IsRunning);
|
||||
_spotlightConversationCount = items.Count(i => i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3);
|
||||
// Count를 한 번의 루프로 계산 (3번 순회 → 1번)
|
||||
int failedCount = 0, runningCount = 0, spotlightCount = 0;
|
||||
foreach (var i in items)
|
||||
{
|
||||
if (i.FailedAgentRunCount > 0) failedCount++;
|
||||
if (i.IsRunning) runningCount++;
|
||||
if (i.FailedAgentRunCount > 0 || i.AgentRunCount >= 3) spotlightCount++;
|
||||
}
|
||||
_failedConversationCount = failedCount;
|
||||
_runningConversationCount = runningCount;
|
||||
_spotlightConversationCount = spotlightCount;
|
||||
UpdateConversationFailureFilterUi();
|
||||
UpdateConversationRunningFilterUi();
|
||||
UpdateConversationQuickStripUi();
|
||||
@@ -137,6 +305,7 @@ public partial class ChatWindow
|
||||
|
||||
private void RenderConversationList(List<ConversationMeta> items)
|
||||
{
|
||||
_lastHoveredConvBorder = null;
|
||||
ConversationPanel.Children.Clear();
|
||||
_pendingConversations = null;
|
||||
|
||||
@@ -211,6 +380,8 @@ public partial class ChatWindow
|
||||
Padding = new Thickness(8, 10, 8, 10),
|
||||
Margin = new Thickness(6, 4, 6, 4),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
// Tag를 설정하지 않아 이벤트 위임 대상에서 제외됨 (별도 핸들러)
|
||||
Tag = new LoadMoreTag(),
|
||||
};
|
||||
var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
|
||||
stack.Children.Add(new TextBlock
|
||||
@@ -238,6 +409,7 @@ public partial class ChatWindow
|
||||
|
||||
var all = _pendingConversations;
|
||||
_pendingConversations = null;
|
||||
_lastHoveredConvBorder = null;
|
||||
ConversationPanel.Children.Clear();
|
||||
|
||||
string? lastGroup = null;
|
||||
@@ -256,6 +428,9 @@ public partial class ChatWindow
|
||||
ConversationPanel.Children.Add(btn);
|
||||
}
|
||||
|
||||
/// <summary>"더 보기" 버튼의 Tag — ConversationItemTag와 구분하여 이벤트 위임에서 무시.</summary>
|
||||
private sealed class LoadMoreTag;
|
||||
|
||||
private static string GetConversationDateGroup(DateTime updatedAt)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
@@ -440,85 +615,15 @@ public partial class ChatWindow
|
||||
|
||||
border.Child = grid;
|
||||
|
||||
var hoverBg = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF));
|
||||
border.MouseEnter += (_, _) =>
|
||||
// A-1: 이벤트 위임 — 개별 람다 대신 Tag에 메타 저장, 부모에서 처리
|
||||
border.Tag = new ConversationItemTag
|
||||
{
|
||||
if (!isSelected)
|
||||
border.Background = hoverBg;
|
||||
catBtn.Visibility = Visibility.Visible;
|
||||
};
|
||||
border.MouseLeave += (_, _) =>
|
||||
{
|
||||
if (!isSelected)
|
||||
border.Background = Brushes.Transparent;
|
||||
catBtn.Visibility = Visibility.Collapsed;
|
||||
};
|
||||
|
||||
border.MouseLeftButtonDown += (_, _) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (isSelected)
|
||||
{
|
||||
EnterTitleEditMode(title, item.Id, titleColor);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_streamingTabs.Contains(_activeTab))
|
||||
{
|
||||
if (_tabStreamCts.TryGetValue(_activeTab, out var convListCts)) convListCts.Cancel();
|
||||
_cursorTimer.Stop();
|
||||
_typingTimer.Stop();
|
||||
_elapsedTimer.Stop();
|
||||
_activeStreamText = null;
|
||||
_elapsedLabel = null;
|
||||
_streamingTabs.Remove(_activeTab);
|
||||
if (_tabStreamCts.Remove(_activeTab, out var removedCts)) removedCts.Dispose();
|
||||
}
|
||||
|
||||
var conv = _storage.Load(item.Id);
|
||||
if (conv == null)
|
||||
return;
|
||||
|
||||
lock (_convLock)
|
||||
{
|
||||
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
|
||||
SyncTabConversationIdsFromSession();
|
||||
}
|
||||
|
||||
SaveLastConversations();
|
||||
UpdateChatTitle();
|
||||
RenderMessages();
|
||||
RefreshConversationList();
|
||||
RefreshDraftQueueUi();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Error($"대화 전환 오류: {ex.Message}");
|
||||
}
|
||||
};
|
||||
|
||||
border.MouseRightButtonUp += (_, me) =>
|
||||
{
|
||||
me.Handled = true;
|
||||
if (!isSelected)
|
||||
{
|
||||
var conv = _storage.Load(item.Id);
|
||||
if (conv != null)
|
||||
{
|
||||
lock (_convLock)
|
||||
{
|
||||
_currentConversation = ChatSession?.SetCurrentConversation(_activeTab, conv, _storage) ?? conv;
|
||||
SyncTabConversationIdsFromSession();
|
||||
}
|
||||
SaveLastConversations();
|
||||
UpdateChatTitle();
|
||||
RenderMessages();
|
||||
RefreshDraftQueueUi();
|
||||
}
|
||||
}
|
||||
|
||||
Dispatcher.BeginInvoke(new Action(() => ShowConversationMenu(item.Id)), DispatcherPriority.Input);
|
||||
Id = item.Id,
|
||||
IsSelected = isSelected,
|
||||
TitleBlock = title,
|
||||
TitleColor = titleColor,
|
||||
CatButton = catBtn,
|
||||
HoverBackground = new SolidColorBrush(Color.FromArgb(0x08, 0xFF, 0xFF, 0xFF)),
|
||||
};
|
||||
|
||||
ConversationPanel.Children.Add(border);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user