sa-assistant 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +32 -0
- agents/orchestrator.py +283 -0
- agents/specialists.py +230 -0
- agentskills/__init__.py +38 -0
- agentskills/discovery.py +107 -0
- agentskills/errors.py +38 -0
- agentskills/models.py +48 -0
- agentskills/parser.py +183 -0
- agentskills/prompt.py +70 -0
- agentskills/tool.py +138 -0
- cli/__init__.py +51 -0
- cli/callback.py +145 -0
- cli/components.py +346 -0
- cli/console.py +106 -0
- cli/mdstream.py +236 -0
- mcp_client/client.py +12 -0
- model/__init__.py +20 -0
- model/load.py +87 -0
- prompts/__init__.py +65 -0
- prompts/system_prompts.py +464 -0
- sa_assistant-0.1.1.dist-info/METADATA +77 -0
- sa_assistant-0.1.1.dist-info/RECORD +25 -0
- sa_assistant-0.1.1.dist-info/WHEEL +5 -0
- sa_assistant-0.1.1.dist-info/entry_points.txt +2 -0
- sa_assistant-0.1.1.dist-info/top_level.txt +6 -0
cli/components.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""SA Assistant CLI Components - UI 컴포넌트
|
|
2
|
+
|
|
3
|
+
Rich 기반 UI 컴포넌트들을 제공합니다.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
from rich.console import Console, Group
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.live import Live
|
|
14
|
+
from rich.spinner import Spinner
|
|
15
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
16
|
+
|
|
17
|
+
from .console import get_console, SA_THEME
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# =============================================================================
|
|
21
|
+
# 배너 및 헬프
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
BANNER = """
|
|
25
|
+
[aws.orange]╔══════════════════════════════════════════════════════════════════════╗[/]
|
|
26
|
+
[aws.orange]║[/] [aws.orange]║[/]
|
|
27
|
+
[aws.orange]║[/] [bold white]AWS SA Assistant[/] [aws.orange]║[/]
|
|
28
|
+
[aws.orange]║[/] [dim]Solutions Architect Professional Agent[/] [aws.orange]║[/]
|
|
29
|
+
[aws.orange]║[/] [aws.orange]║[/]
|
|
30
|
+
[aws.orange]║[/] [cyan]Powered by Claude with Extended Thinking[/] [aws.orange]║[/]
|
|
31
|
+
[aws.orange]║[/] [cyan]+ Guru Sub-agents (Bezos, Vogels, Naval, Feynman)[/] [aws.orange]║[/]
|
|
32
|
+
[aws.orange]║[/] [cyan]+ Specialist Agents (Explorer, Researcher, Reviewer)[/] [aws.orange]║[/]
|
|
33
|
+
[aws.orange]║[/] [aws.orange]║[/]
|
|
34
|
+
[aws.orange]╚══════════════════════════════════════════════════════════════════════╝[/]
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def print_banner() -> None:
|
|
39
|
+
"""SA Assistant 배너를 출력합니다."""
|
|
40
|
+
console = get_console()
|
|
41
|
+
console.print(BANNER)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def print_help() -> None:
|
|
45
|
+
"""도움말을 출력합니다."""
|
|
46
|
+
console = get_console()
|
|
47
|
+
|
|
48
|
+
help_table = Table(show_header=False, box=None, padding=(0, 2))
|
|
49
|
+
help_table.add_column("Command", style="cyan")
|
|
50
|
+
help_table.add_column("Description", style="dim")
|
|
51
|
+
|
|
52
|
+
help_table.add_row("Enter", "메시지 전송")
|
|
53
|
+
help_table.add_row("Esc+Enter / Alt+Enter", "줄바꿈")
|
|
54
|
+
help_table.add_row("↑ / ↓", "히스토리 탐색")
|
|
55
|
+
help_table.add_row("Ctrl+C", "입력 취소")
|
|
56
|
+
help_table.add_row("exit / quit", "종료")
|
|
57
|
+
|
|
58
|
+
console.print()
|
|
59
|
+
console.print(
|
|
60
|
+
Panel(
|
|
61
|
+
help_table,
|
|
62
|
+
title="[bold]키보드 단축키[/]",
|
|
63
|
+
border_style="dim",
|
|
64
|
+
padding=(0, 1),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def print_specialists_info(specialists: list[str]) -> None:
|
|
70
|
+
"""사용 가능한 Specialist 정보를 출력합니다."""
|
|
71
|
+
console = get_console()
|
|
72
|
+
|
|
73
|
+
specialist_descriptions = {
|
|
74
|
+
"explorer": ("🔍", "아키텍처 분석, 다이어그램 해석, 코드 탐색"),
|
|
75
|
+
"researcher": ("📚", "AWS 문서 검색, Best Practice, 가격 정보"),
|
|
76
|
+
"reviewer": ("✅", "Well-Architected 검토, 보안 분석, 개선 권장"),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
guru_descriptions = {
|
|
80
|
+
"bezos": ("👔", "고객 중심 사고, 장기 비전"),
|
|
81
|
+
"vogels": ("🔧", "분산 시스템, 확장성"),
|
|
82
|
+
"naval": ("💡", "레버리지, ROI 분석"),
|
|
83
|
+
"feynman": ("🎓", "개념 단순화, 설명"),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
table = Table(show_header=True, header_style="bold", box=None)
|
|
87
|
+
table.add_column("Agent", style="cyan")
|
|
88
|
+
table.add_column("Type", style="dim")
|
|
89
|
+
table.add_column("전문 분야")
|
|
90
|
+
|
|
91
|
+
for name, (emoji, desc) in specialist_descriptions.items():
|
|
92
|
+
table.add_row(f"{emoji} {name}", "Specialist", desc)
|
|
93
|
+
|
|
94
|
+
for name, (emoji, desc) in guru_descriptions.items():
|
|
95
|
+
table.add_row(f"{emoji} {name}", "Guru", desc)
|
|
96
|
+
|
|
97
|
+
console.print()
|
|
98
|
+
console.print(
|
|
99
|
+
Panel(
|
|
100
|
+
table,
|
|
101
|
+
title="[bold]사용 가능한 에이전트[/]",
|
|
102
|
+
border_style="cyan",
|
|
103
|
+
padding=(0, 1),
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# =============================================================================
|
|
109
|
+
# 에이전트 패널
|
|
110
|
+
# =============================================================================
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AgentPanel:
|
|
114
|
+
"""에이전트 실행 상태를 표시하는 패널"""
|
|
115
|
+
|
|
116
|
+
AGENT_COLORS = {
|
|
117
|
+
"orchestrator": "aws.orange",
|
|
118
|
+
"guru": "agent.guru",
|
|
119
|
+
"specialist": "agent.specialist",
|
|
120
|
+
"bezos": "agent.guru",
|
|
121
|
+
"vogels": "agent.guru",
|
|
122
|
+
"naval": "agent.guru",
|
|
123
|
+
"feynman": "agent.guru",
|
|
124
|
+
"explorer": "agent.explorer",
|
|
125
|
+
"researcher": "agent.researcher",
|
|
126
|
+
"reviewer": "agent.reviewer",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
AGENT_EMOJIS = {
|
|
130
|
+
"orchestrator": "🎯",
|
|
131
|
+
"bezos": "👔",
|
|
132
|
+
"vogels": "🔧",
|
|
133
|
+
"naval": "💡",
|
|
134
|
+
"feynman": "🎓",
|
|
135
|
+
"explorer": "🔍",
|
|
136
|
+
"researcher": "📚",
|
|
137
|
+
"reviewer": "✅",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
def __init__(self, agent_name: str, agent_type: str = "specialist"):
|
|
141
|
+
self.agent_name = agent_name
|
|
142
|
+
self.agent_type = agent_type
|
|
143
|
+
self.console = get_console()
|
|
144
|
+
|
|
145
|
+
def print_start(self, task: str = None) -> None:
|
|
146
|
+
"""에이전트 시작을 표시합니다."""
|
|
147
|
+
emoji = self.AGENT_EMOJIS.get(self.agent_name.lower(), "🤖")
|
|
148
|
+
color = self.AGENT_COLORS.get(self.agent_name.lower(), "cyan")
|
|
149
|
+
|
|
150
|
+
text = f"{emoji} [{color}]{self.agent_name.upper()}[/] 에이전트 호출 중..."
|
|
151
|
+
if task:
|
|
152
|
+
text += f" ({task[:50]}...)" if len(task) > 50 else f" ({task})"
|
|
153
|
+
|
|
154
|
+
self.console.print(text)
|
|
155
|
+
|
|
156
|
+
def print_result(self, success: bool = True, message: str = None) -> None:
|
|
157
|
+
"""에이전트 결과를 표시합니다."""
|
|
158
|
+
emoji = self.AGENT_EMOJIS.get(self.agent_name.lower(), "🤖")
|
|
159
|
+
color = self.AGENT_COLORS.get(self.agent_name.lower(), "cyan")
|
|
160
|
+
|
|
161
|
+
if success:
|
|
162
|
+
status = "[success]완료[/]"
|
|
163
|
+
else:
|
|
164
|
+
status = "[error]실패[/]"
|
|
165
|
+
|
|
166
|
+
text = f"{emoji} [{color}]{self.agent_name.upper()}[/] {status}"
|
|
167
|
+
if message:
|
|
168
|
+
text += f": {message}"
|
|
169
|
+
|
|
170
|
+
self.console.print(text)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# =============================================================================
|
|
174
|
+
# 도구 상태
|
|
175
|
+
# =============================================================================
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class ToolStatus:
|
|
179
|
+
"""도구 실행 상태를 관리합니다."""
|
|
180
|
+
|
|
181
|
+
TOOL_EMOJIS = {
|
|
182
|
+
"consult_guru": "🧙",
|
|
183
|
+
"consult_specialist": "🔬",
|
|
184
|
+
"file_read": "📄",
|
|
185
|
+
"file_write": "💾",
|
|
186
|
+
"shell": "🖥️",
|
|
187
|
+
"image_reader": "🖼️",
|
|
188
|
+
"code_interpreter": "⚡",
|
|
189
|
+
"skill": "📚",
|
|
190
|
+
"list_gurus": "📋",
|
|
191
|
+
"list_specialists": "📋",
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
def __init__(self):
|
|
195
|
+
self.console = get_console()
|
|
196
|
+
self._active_tools: Dict[str, str] = {} # tool_id -> tool_name
|
|
197
|
+
|
|
198
|
+
def tool_start(
|
|
199
|
+
self, tool_id: str, tool_name: str, args: Dict[str, Any] = None
|
|
200
|
+
) -> None:
|
|
201
|
+
"""도구 시작을 표시합니다."""
|
|
202
|
+
if tool_id in self._active_tools:
|
|
203
|
+
return # 이미 표시됨
|
|
204
|
+
|
|
205
|
+
self._active_tools[tool_id] = tool_name
|
|
206
|
+
emoji = self.TOOL_EMOJIS.get(tool_name, "🔧")
|
|
207
|
+
|
|
208
|
+
# 특별한 도구들에 대한 상세 정보
|
|
209
|
+
detail = ""
|
|
210
|
+
if args:
|
|
211
|
+
if tool_name == "consult_guru":
|
|
212
|
+
guru_name = args.get("guru_name", "")
|
|
213
|
+
detail = f" → {guru_name.upper()}"
|
|
214
|
+
elif tool_name == "consult_specialist":
|
|
215
|
+
specialist_name = args.get("specialist_name", "")
|
|
216
|
+
detail = f" → {specialist_name.upper()}"
|
|
217
|
+
elif tool_name == "skill":
|
|
218
|
+
skill_name = args.get("skill_name", "")
|
|
219
|
+
detail = f" → {skill_name}"
|
|
220
|
+
elif tool_name in ("file_read", "file_write"):
|
|
221
|
+
path = args.get("path", args.get("file_path", ""))
|
|
222
|
+
if path:
|
|
223
|
+
# 경로가 길면 축약
|
|
224
|
+
if len(path) > 40:
|
|
225
|
+
path = "..." + path[-37:]
|
|
226
|
+
detail = f" → {path}"
|
|
227
|
+
|
|
228
|
+
self.console.print(f"[tool.running]{emoji} {tool_name}{detail}[/]")
|
|
229
|
+
|
|
230
|
+
def tool_end(self, tool_id: str, success: bool = True, error: str = None) -> None:
|
|
231
|
+
"""도구 종료를 표시합니다. (선택적)"""
|
|
232
|
+
if tool_id in self._active_tools:
|
|
233
|
+
del self._active_tools[tool_id]
|
|
234
|
+
|
|
235
|
+
# 에러가 있을 때만 표시
|
|
236
|
+
if not success and error:
|
|
237
|
+
self.console.print(f"[tool.error]✗ 오류: {error}[/]")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# =============================================================================
|
|
241
|
+
# 응답 렌더러
|
|
242
|
+
# =============================================================================
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ResponseRenderer:
|
|
246
|
+
"""에이전트 응답을 렌더링합니다."""
|
|
247
|
+
|
|
248
|
+
def __init__(self):
|
|
249
|
+
self.console = get_console()
|
|
250
|
+
self._buffer = ""
|
|
251
|
+
|
|
252
|
+
def stream_text(self, text: str) -> None:
|
|
253
|
+
"""스트리밍 텍스트를 출력합니다."""
|
|
254
|
+
print(text, end="", flush=True)
|
|
255
|
+
self._buffer += text
|
|
256
|
+
|
|
257
|
+
def render_markdown(self, text: str) -> None:
|
|
258
|
+
"""마크다운을 렌더링합니다."""
|
|
259
|
+
md = Markdown(text)
|
|
260
|
+
self.console.print(md)
|
|
261
|
+
|
|
262
|
+
def render_code(self, code: str, language: str = "python") -> None:
|
|
263
|
+
"""코드를 구문 강조하여 렌더링합니다."""
|
|
264
|
+
syntax = Syntax(code, language, theme="monokai", line_numbers=True)
|
|
265
|
+
self.console.print(syntax)
|
|
266
|
+
|
|
267
|
+
def render_thinking(self, text: str) -> None:
|
|
268
|
+
"""Thinking 텍스트를 렌더링합니다."""
|
|
269
|
+
self.console.print(
|
|
270
|
+
Panel(
|
|
271
|
+
Text(text, style="text.thinking"),
|
|
272
|
+
title="[dim]💭 Thinking[/]",
|
|
273
|
+
border_style="dim",
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
def clear_buffer(self) -> str:
|
|
278
|
+
"""버퍼를 비우고 내용을 반환합니다."""
|
|
279
|
+
content = self._buffer
|
|
280
|
+
self._buffer = ""
|
|
281
|
+
return content
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# =============================================================================
|
|
285
|
+
# 프로그레스 및 스피너
|
|
286
|
+
# =============================================================================
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def create_spinner(text: str = "처리 중...") -> Progress:
|
|
290
|
+
"""스피너 Progress 인스턴스를 생성합니다."""
|
|
291
|
+
return Progress(
|
|
292
|
+
SpinnerColumn(),
|
|
293
|
+
TextColumn("[progress.description]{task.description}"),
|
|
294
|
+
console=get_console(),
|
|
295
|
+
transient=True,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class ThinkingSpinner:
|
|
300
|
+
"""Thinking 상태를 표시하는 스피너"""
|
|
301
|
+
|
|
302
|
+
def __init__(self):
|
|
303
|
+
self.console = get_console()
|
|
304
|
+
self._live: Optional[Live] = None
|
|
305
|
+
|
|
306
|
+
def start(self) -> None:
|
|
307
|
+
"""스피너를 시작합니다."""
|
|
308
|
+
spinner = Spinner("dots", text="[text.thinking]생각 중...[/]")
|
|
309
|
+
self._live = Live(spinner, console=self.console, transient=True)
|
|
310
|
+
self._live.start()
|
|
311
|
+
|
|
312
|
+
def update(self, text: str) -> None:
|
|
313
|
+
"""스피너 텍스트를 업데이트합니다."""
|
|
314
|
+
if self._live:
|
|
315
|
+
spinner = Spinner("dots", text=f"[text.thinking]{text}[/]")
|
|
316
|
+
self._live.update(spinner)
|
|
317
|
+
|
|
318
|
+
def stop(self) -> None:
|
|
319
|
+
"""스피너를 중지합니다."""
|
|
320
|
+
if self._live:
|
|
321
|
+
self._live.stop()
|
|
322
|
+
self._live = None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# =============================================================================
|
|
326
|
+
# 유틸리티
|
|
327
|
+
# =============================================================================
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def format_duration(seconds: float) -> str:
|
|
331
|
+
"""시간을 포맷팅합니다."""
|
|
332
|
+
if seconds < 1:
|
|
333
|
+
return f"{seconds * 1000:.0f}ms"
|
|
334
|
+
elif seconds < 60:
|
|
335
|
+
return f"{seconds:.1f}s"
|
|
336
|
+
else:
|
|
337
|
+
minutes = int(seconds // 60)
|
|
338
|
+
secs = seconds % 60
|
|
339
|
+
return f"{minutes}m {secs:.0f}s"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def truncate_text(text: str, max_length: int = 100) -> str:
|
|
343
|
+
"""텍스트를 축약합니다."""
|
|
344
|
+
if len(text) <= max_length:
|
|
345
|
+
return text
|
|
346
|
+
return text[: max_length - 3] + "..."
|
cli/console.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""SA Assistant Console - Rich 기반 콘솔 래퍼
|
|
2
|
+
|
|
3
|
+
Rich 라이브러리를 사용하여 터미널 UI를 개선합니다.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.theme import Theme
|
|
9
|
+
from rich.style import Style
|
|
10
|
+
|
|
11
|
+
# SA Assistant 커스텀 테마 - AWS 오렌지 기반
|
|
12
|
+
SA_THEME = Theme(
|
|
13
|
+
{
|
|
14
|
+
# AWS 컬러 스킴
|
|
15
|
+
"aws.orange": "bold #FF9900",
|
|
16
|
+
"aws.dark": "#232F3E",
|
|
17
|
+
"aws.blue": "#1A73E8",
|
|
18
|
+
# 에이전트 타입별 색상
|
|
19
|
+
"agent.orchestrator": "bold #FF9900",
|
|
20
|
+
"agent.guru": "bold magenta",
|
|
21
|
+
"agent.specialist": "bold cyan",
|
|
22
|
+
"agent.explorer": "bold green",
|
|
23
|
+
"agent.researcher": "bold blue",
|
|
24
|
+
"agent.reviewer": "bold yellow",
|
|
25
|
+
# 도구 상태 색상
|
|
26
|
+
"tool.running": "cyan",
|
|
27
|
+
"tool.success": "green",
|
|
28
|
+
"tool.error": "red",
|
|
29
|
+
"tool.pending": "dim",
|
|
30
|
+
# 텍스트 스타일
|
|
31
|
+
"text.user": "bold green",
|
|
32
|
+
"text.assistant": "bold #FF9900",
|
|
33
|
+
"text.thinking": "dim italic",
|
|
34
|
+
"text.error": "bold red",
|
|
35
|
+
"text.warning": "yellow",
|
|
36
|
+
"text.info": "cyan",
|
|
37
|
+
"text.dim": "dim",
|
|
38
|
+
# 특수 용도
|
|
39
|
+
"highlight": "bold #FF9900",
|
|
40
|
+
"success": "bold green",
|
|
41
|
+
"error": "bold red",
|
|
42
|
+
"warning": "yellow",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SAConsole:
|
|
48
|
+
"""SA Assistant 전용 Rich Console 래퍼
|
|
49
|
+
|
|
50
|
+
싱글톤 패턴으로 전역 콘솔 인스턴스를 관리합니다.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_instance: Optional[Console] = None
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def get_console(cls) -> Console:
|
|
57
|
+
"""전역 Console 인스턴스를 반환합니다."""
|
|
58
|
+
if cls._instance is None:
|
|
59
|
+
cls._instance = Console(
|
|
60
|
+
theme=SA_THEME,
|
|
61
|
+
highlight=True,
|
|
62
|
+
markup=True,
|
|
63
|
+
emoji=True,
|
|
64
|
+
)
|
|
65
|
+
return cls._instance
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def reset(cls) -> None:
|
|
69
|
+
"""Console 인스턴스를 리셋합니다. (테스트용)"""
|
|
70
|
+
cls._instance = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# 편의 함수들
|
|
74
|
+
def get_console() -> Console:
|
|
75
|
+
"""전역 Console 인스턴스를 반환합니다."""
|
|
76
|
+
return SAConsole.get_console()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_styled(text: str, style: str = None, **kwargs) -> None:
|
|
80
|
+
"""스타일이 적용된 텍스트를 출력합니다."""
|
|
81
|
+
console = get_console()
|
|
82
|
+
console.print(text, style=style, **kwargs)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def print_error(text: str) -> None:
|
|
86
|
+
"""에러 메시지를 출력합니다."""
|
|
87
|
+
console = get_console()
|
|
88
|
+
console.print(f"[text.error]✗ {text}[/]")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def print_warning(text: str) -> None:
|
|
92
|
+
"""경고 메시지를 출력합니다."""
|
|
93
|
+
console = get_console()
|
|
94
|
+
console.print(f"[text.warning]⚠ {text}[/]")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def print_info(text: str) -> None:
|
|
98
|
+
"""정보 메시지를 출력합니다."""
|
|
99
|
+
console = get_console()
|
|
100
|
+
console.print(f"[text.info]ℹ {text}[/]")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def print_success(text: str) -> None:
|
|
104
|
+
"""성공 메시지를 출력합니다."""
|
|
105
|
+
console = get_console()
|
|
106
|
+
console.print(f"[success]✓ {text}[/]")
|
cli/mdstream.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""SA Assistant Markdown Stream - Streaming markdown renderer with Rich Live display."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import time
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from rich import box
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.markdown import Markdown, CodeBlock, Heading
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from .console import get_console
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SACodeBlock(CodeBlock):
|
|
19
|
+
"""Code block with minimal padding and monokai theme."""
|
|
20
|
+
|
|
21
|
+
def __rich_console__(self, console, options):
|
|
22
|
+
code = str(self.text).rstrip()
|
|
23
|
+
syntax = Syntax(
|
|
24
|
+
code,
|
|
25
|
+
self.lexer_name,
|
|
26
|
+
theme="monokai",
|
|
27
|
+
word_wrap=True,
|
|
28
|
+
padding=(1, 2),
|
|
29
|
+
background_color="#1a1a1a",
|
|
30
|
+
)
|
|
31
|
+
yield syntax
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SAHeading(Heading):
|
|
35
|
+
"""Left-aligned heading with AWS color scheme."""
|
|
36
|
+
|
|
37
|
+
def __rich_console__(self, console, options):
|
|
38
|
+
text = self.text
|
|
39
|
+
text.justify = "left"
|
|
40
|
+
|
|
41
|
+
if self.tag == "h1":
|
|
42
|
+
yield Panel(
|
|
43
|
+
text,
|
|
44
|
+
box=box.HEAVY,
|
|
45
|
+
style="bold #FF9900",
|
|
46
|
+
border_style="#FF9900",
|
|
47
|
+
)
|
|
48
|
+
elif self.tag == "h2":
|
|
49
|
+
yield Text("")
|
|
50
|
+
text.stylize("bold cyan")
|
|
51
|
+
yield text
|
|
52
|
+
else:
|
|
53
|
+
text.stylize("bold")
|
|
54
|
+
yield text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SAMarkdown(Markdown):
|
|
58
|
+
"""Custom markdown renderer with SA Assistant styling."""
|
|
59
|
+
|
|
60
|
+
elements = {
|
|
61
|
+
**Markdown.elements,
|
|
62
|
+
"fence": SACodeBlock,
|
|
63
|
+
"code_block": SACodeBlock,
|
|
64
|
+
"heading_open": SAHeading,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class MarkdownStream:
|
|
69
|
+
"""Streaming markdown renderer using Rich Live display.
|
|
70
|
+
|
|
71
|
+
Renders LLM responses in real-time with proper markdown formatting.
|
|
72
|
+
Uses sliding window approach: stable lines go to console,
|
|
73
|
+
unstable lines stay in Live area for smooth updates.
|
|
74
|
+
|
|
75
|
+
Usage:
|
|
76
|
+
stream = MarkdownStream()
|
|
77
|
+
for chunk in llm_response:
|
|
78
|
+
stream.update(accumulated_text)
|
|
79
|
+
stream.update(final_text, final=True)
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
MIN_FPS = 20
|
|
83
|
+
MAX_DELAY = 2.0
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
console: Optional[Console] = None,
|
|
88
|
+
live_window: int = 6,
|
|
89
|
+
min_delay: float = 1.0 / 20,
|
|
90
|
+
):
|
|
91
|
+
self.console = console or get_console()
|
|
92
|
+
self.live_window = live_window
|
|
93
|
+
self.min_delay = min_delay
|
|
94
|
+
|
|
95
|
+
self.live: Optional[Live] = None
|
|
96
|
+
self.when: float = 0
|
|
97
|
+
self.printed: list = []
|
|
98
|
+
self._live_started: bool = False
|
|
99
|
+
self._text_buffer: str = ""
|
|
100
|
+
|
|
101
|
+
def _render_markdown_to_lines(self, text: str) -> list:
|
|
102
|
+
string_io = io.StringIO()
|
|
103
|
+
temp_console = Console(
|
|
104
|
+
file=string_io, force_terminal=True, width=self.console.width
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
markdown = SAMarkdown(text)
|
|
108
|
+
temp_console.print(markdown)
|
|
109
|
+
output = string_io.getvalue()
|
|
110
|
+
|
|
111
|
+
return output.splitlines(keepends=True)
|
|
112
|
+
|
|
113
|
+
def update(self, text: str, final: bool = False) -> None:
|
|
114
|
+
"""Update displayed markdown content."""
|
|
115
|
+
self._text_buffer = text
|
|
116
|
+
|
|
117
|
+
if not self._live_started:
|
|
118
|
+
self.live = Live(
|
|
119
|
+
Text(""),
|
|
120
|
+
console=self.console,
|
|
121
|
+
refresh_per_second=1.0 / self.min_delay,
|
|
122
|
+
transient=True,
|
|
123
|
+
)
|
|
124
|
+
self.live.start()
|
|
125
|
+
self._live_started = True
|
|
126
|
+
|
|
127
|
+
now = time.time()
|
|
128
|
+
if not final and now - self.when < self.min_delay:
|
|
129
|
+
return
|
|
130
|
+
self.when = now
|
|
131
|
+
|
|
132
|
+
start = time.time()
|
|
133
|
+
lines = self._render_markdown_to_lines(text)
|
|
134
|
+
render_time = time.time() - start
|
|
135
|
+
|
|
136
|
+
self.min_delay = min(max(render_time * 10, 1.0 / self.MIN_FPS), self.MAX_DELAY)
|
|
137
|
+
|
|
138
|
+
num_lines = len(lines)
|
|
139
|
+
|
|
140
|
+
if not final:
|
|
141
|
+
num_lines -= self.live_window
|
|
142
|
+
|
|
143
|
+
if final or num_lines > 0:
|
|
144
|
+
num_printed = len(self.printed)
|
|
145
|
+
show_count = num_lines - num_printed
|
|
146
|
+
|
|
147
|
+
if show_count > 0 and self.live is not None:
|
|
148
|
+
show = lines[num_printed:num_lines]
|
|
149
|
+
show_text = "".join(show)
|
|
150
|
+
show_rich = Text.from_ansi(show_text)
|
|
151
|
+
self.live.console.print(show_rich, end="")
|
|
152
|
+
|
|
153
|
+
self.printed = lines[:num_lines]
|
|
154
|
+
|
|
155
|
+
if final:
|
|
156
|
+
if self.live:
|
|
157
|
+
self.live.update(Text(""))
|
|
158
|
+
self.live.stop()
|
|
159
|
+
self.live = None
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
rest = lines[num_lines:]
|
|
163
|
+
rest_text = "".join(rest)
|
|
164
|
+
rest_rich = Text.from_ansi(rest_text)
|
|
165
|
+
if self.live:
|
|
166
|
+
self.live.update(rest_rich)
|
|
167
|
+
|
|
168
|
+
def finish(self) -> None:
|
|
169
|
+
"""Finalize stream and output remaining content."""
|
|
170
|
+
if self._text_buffer:
|
|
171
|
+
self.update(self._text_buffer, final=True)
|
|
172
|
+
elif self.live:
|
|
173
|
+
self.live.stop()
|
|
174
|
+
self.live = None
|
|
175
|
+
|
|
176
|
+
def __del__(self):
|
|
177
|
+
if self.live:
|
|
178
|
+
try:
|
|
179
|
+
self.live.stop()
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class SimpleMarkdownStream:
|
|
185
|
+
"""Simple markdown stream - renders final result only.
|
|
186
|
+
|
|
187
|
+
Outputs plain text during streaming, renders full markdown on completion.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(self, console: Optional[Console] = None):
|
|
191
|
+
self.console = console or get_console()
|
|
192
|
+
self._buffer = ""
|
|
193
|
+
self._last_printed_len = 0
|
|
194
|
+
|
|
195
|
+
def update(self, text: str, final: bool = False) -> None:
|
|
196
|
+
"""Update text content."""
|
|
197
|
+
if final:
|
|
198
|
+
print()
|
|
199
|
+
self.console.print(SAMarkdown(text))
|
|
200
|
+
else:
|
|
201
|
+
new_text = text[self._last_printed_len :]
|
|
202
|
+
if new_text:
|
|
203
|
+
print(new_text, end="", flush=True)
|
|
204
|
+
self._last_printed_len = len(text)
|
|
205
|
+
|
|
206
|
+
self._buffer = text
|
|
207
|
+
|
|
208
|
+
def finish(self) -> None:
|
|
209
|
+
"""Finalize stream."""
|
|
210
|
+
if self._buffer:
|
|
211
|
+
self.update(self._buffer, final=True)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def render_markdown(text: str, console: Optional[Console] = None) -> None:
|
|
215
|
+
"""Render markdown text to console."""
|
|
216
|
+
console = console or get_console()
|
|
217
|
+
console.print(SAMarkdown(text))
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def render_code(
|
|
221
|
+
code: str,
|
|
222
|
+
language: str = "python",
|
|
223
|
+
console: Optional[Console] = None,
|
|
224
|
+
line_numbers: bool = True,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Render code with syntax highlighting."""
|
|
227
|
+
console = console or get_console()
|
|
228
|
+
syntax = Syntax(
|
|
229
|
+
code,
|
|
230
|
+
language,
|
|
231
|
+
theme="monokai",
|
|
232
|
+
line_numbers=line_numbers,
|
|
233
|
+
word_wrap=True,
|
|
234
|
+
background_color="#1a1a1a",
|
|
235
|
+
)
|
|
236
|
+
console.print(syntax)
|
mcp_client/client.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
2
|
+
from strands.tools.mcp.mcp_client import MCPClient
|
|
3
|
+
|
|
4
|
+
# ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication
|
|
5
|
+
EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp"
|
|
6
|
+
|
|
7
|
+
def get_streamable_http_mcp_client() -> MCPClient:
|
|
8
|
+
"""
|
|
9
|
+
Returns an MCP Client compatible with Strands
|
|
10
|
+
"""
|
|
11
|
+
# to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"}
|
|
12
|
+
return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT))
|