ltcai 0.1.16 → 0.1.17
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.
- package/README.md +6 -6
- package/docs/CHANGELOG.md +24 -0
- package/llm_router.py +13 -0
- package/package.json +1 -1
- package/server.py +111 -31
- package/static/chat.html +266 -2
- package/telegram_bot.py +185 -17
package/README.md
CHANGED
|
@@ -14,10 +14,10 @@ Lattice AI는 개인 개발자가 로컬 모델, 클라우드 모델, 에이전
|
|
|
14
14
|
|
|
15
15
|
### 현재 배포 버전
|
|
16
16
|
|
|
17
|
-
- `PyPI`: `ltcai==0.1.
|
|
18
|
-
- `npm`: `ltcai@0.1.
|
|
19
|
-
- `VS Code Marketplace`: `parktaesoo.ltcai@0.1.
|
|
20
|
-
- `Open VSX`: `parktaesoo.ltcai@0.1.
|
|
17
|
+
- `PyPI`: `ltcai==0.1.17`
|
|
18
|
+
- `npm`: `ltcai@0.1.17`
|
|
19
|
+
- `VS Code Marketplace`: `parktaesoo.ltcai@0.1.17`
|
|
20
|
+
- `Open VSX`: `parktaesoo.ltcai@0.1.17`
|
|
21
21
|
|
|
22
22
|
### 왜 Lattice AI인가
|
|
23
23
|
|
|
@@ -248,7 +248,7 @@ docker run --rm -p 4825:4825 \
|
|
|
248
248
|
|
|
249
249
|
### 릴리스 체크
|
|
250
250
|
|
|
251
|
-
`0.1.
|
|
251
|
+
`0.1.17 릴리스는 아래 네 채널을 동일 버전으로 맞춥니다.
|
|
252
252
|
|
|
253
253
|
- `npm`
|
|
254
254
|
- `PyPI`
|
|
@@ -359,7 +359,7 @@ launchctl load ~/Library/LaunchAgents/com.ltcai.plist
|
|
|
359
359
|
|
|
360
360
|
## 릴리스 노트
|
|
361
361
|
|
|
362
|
-
현재 버전: **0.1.
|
|
362
|
+
현재 버전: **0.1.17** — 자세한 변경 이력은 [docs/CHANGELOG.md](docs/CHANGELOG.md) 참고.
|
|
363
363
|
|
|
364
364
|
## 라이선스
|
|
365
365
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.17] - 2026-05-22
|
|
4
|
+
|
|
5
|
+
### Multi-LLM Pipeline
|
|
6
|
+
|
|
7
|
+
- **파이프라인 UI 카드** — ops 대시보드의 ACTIVE MODEL 카드와 PRIVATE VPC 카드 사이에 PIPELINE 카드 추가
|
|
8
|
+
- 파이프라인 비활성 시: "멀티 LLM 파이프라인 / Plan → Execute → Review 모델 설정" 표시
|
|
9
|
+
- 파이프라인 활성 시: "Pipeline ON / P:모델명 E:모델명 R:모델명" 으로 현재 설정 표시
|
|
10
|
+
- **멀티 LLM 에이전트 파이프라인** — Planning / Executing / Reviewing 3단계에 각각 다른 LLM 지정 가능
|
|
11
|
+
- 모달에서 각 단계별 모델 선택 (로드된 로컬 모델 + 클라우드 프로바이더 자동 목록 구성)
|
|
12
|
+
- 하나의 모델을 모든 단계에 사용해도 정상 동작
|
|
13
|
+
- **Human-in-the-loop** — 파이프라인 활성화 시 Planning 완료 후 사용자 승인을 기다렸다가 Execute 단계로 진행
|
|
14
|
+
- 웹 UI: 플랜 승인 카드(`✅ 승인 / ❌ 취소`) 렌더링
|
|
15
|
+
- Telegram 봇: 인라인 버튼으로 플랜 승인/취소
|
|
16
|
+
- **`/agent/resume` 엔드포인트** — `context_id`와 `approved` 필드로 대기 중인 에이전트 재개 또는 취소
|
|
17
|
+
- **`AgentRequest` 확장** — `planning_model`, `executing_model`, `reviewing_model`, `human_in_loop` 파라미터 추가
|
|
18
|
+
- **`LLMRouter.generate_as(model_id, ...)`** — 현재 모델을 임시 교체해 지정 모델로 생성 후 원복하는 헬퍼
|
|
19
|
+
- **Telegram 봇 인증 수정** — 서버 호출 시 `~/.ltcai/sessions.json`에서 어드민 세션 토큰을 읽어 쿠키로 전달
|
|
20
|
+
- **Telegram SSE 파싱** — `/chat` 스트리밍 응답(`text/event-stream`)을 올바르게 파싱하도록 수정
|
|
21
|
+
- **`_sessions_file()` 버그 수정** — 정의 이전에 전역 `DATA_DIR` 참조하던 문제 해결 (함수 내 경로 직접 계산)
|
|
22
|
+
|
|
23
|
+
### Release
|
|
24
|
+
- 배포 버전을 `0.1.17`로 상향
|
|
25
|
+
- 대상 채널: `npm`, `PyPI`, `VS Code Marketplace`, `Open VSX`
|
|
26
|
+
|
|
3
27
|
## [0.1.16] - 2026-05-22
|
|
4
28
|
|
|
5
29
|
### First-user admin bootstrap
|
package/llm_router.py
CHANGED
|
@@ -472,6 +472,19 @@ class LLMRouter:
|
|
|
472
472
|
print(f"⚠️ VLM chat template fallback: {e}")
|
|
473
473
|
return self._build_prompt(message, context, processor)
|
|
474
474
|
|
|
475
|
+
async def generate_as(self, model_id: str | None, message: str, context: Optional[str] = None, max_tokens: int = 4096, temperature: float = 0.2) -> str:
|
|
476
|
+
"""Generate using a specific model, temporarily switching if needed. Falls back to current model if model_id is None or not loaded."""
|
|
477
|
+
if not model_id or model_id == self._current:
|
|
478
|
+
return await self.generate(message, context, max_tokens, temperature)
|
|
479
|
+
if model_id not in self._cache:
|
|
480
|
+
raise ValueError(f"Model '{model_id}' is not loaded. Load it first via /models/load.")
|
|
481
|
+
prev = self._current
|
|
482
|
+
self._current = model_id
|
|
483
|
+
try:
|
|
484
|
+
return await self.generate(message, context, max_tokens, temperature)
|
|
485
|
+
finally:
|
|
486
|
+
self._current = prev
|
|
487
|
+
|
|
475
488
|
async def generate(self, message: str, context: Optional[str] = None, max_tokens: int = 4096, temperature: float = 0.2, image_data: Optional[str] = None) -> str:
|
|
476
489
|
if not self._current: return "No model."
|
|
477
490
|
self._touch()
|
package/package.json
CHANGED
package/server.py
CHANGED
|
@@ -243,7 +243,9 @@ _SESSION_REFRESH_THRESHOLD = 60 * 15 # only persist if >15 min since last bump
|
|
|
243
243
|
_sessions_lock = threading.Lock()
|
|
244
244
|
|
|
245
245
|
def _sessions_file() -> Path:
|
|
246
|
-
|
|
246
|
+
data_dir = Path(os.getenv("LATTICEAI_DATA_DIR") or (Path.home() / ".ltcai"))
|
|
247
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
return data_dir / "sessions.json"
|
|
247
249
|
|
|
248
250
|
def _load_sessions() -> Dict[str, tuple]:
|
|
249
251
|
try:
|
|
@@ -2033,6 +2035,20 @@ class AgentRequest(BaseModel):
|
|
|
2033
2035
|
temperature: float = 0.1
|
|
2034
2036
|
user_email: Optional[str] = None
|
|
2035
2037
|
user_nickname: Optional[str] = None
|
|
2038
|
+
# Multi-LLM pipeline: per-phase model override (None = use current loaded model)
|
|
2039
|
+
planning_model: Optional[str] = None
|
|
2040
|
+
executing_model: Optional[str] = None
|
|
2041
|
+
reviewing_model: Optional[str] = None
|
|
2042
|
+
# When True: pause after planning and wait for /agent/resume
|
|
2043
|
+
human_in_loop: bool = False
|
|
2044
|
+
|
|
2045
|
+
|
|
2046
|
+
class AgentResumeRequest(BaseModel):
|
|
2047
|
+
context_id: str
|
|
2048
|
+
approved: bool = True
|
|
2049
|
+
modified_plan: Optional[dict] = None
|
|
2050
|
+
executing_model: Optional[str] = None
|
|
2051
|
+
reviewing_model: Optional[str] = None
|
|
2036
2052
|
|
|
2037
2053
|
|
|
2038
2054
|
class AgentEvalRequest(BaseModel):
|
|
@@ -2058,17 +2074,25 @@ AGENT_TERMINAL_STATES = frozenset({AgentState.DONE, AgentState.FAILED})
|
|
|
2058
2074
|
class AgentRunContext:
|
|
2059
2075
|
"""Mutable state carrier passed through all agent phases."""
|
|
2060
2076
|
__slots__ = ("state", "plan", "transcript", "retry_count",
|
|
2061
|
-
"state_history", "corrections", "final_message", "rollback_log"
|
|
2077
|
+
"state_history", "corrections", "final_message", "rollback_log",
|
|
2078
|
+
"executing_model", "reviewing_model")
|
|
2062
2079
|
|
|
2063
2080
|
def __init__(self) -> None:
|
|
2064
|
-
self.state:
|
|
2065
|
-
self.plan:
|
|
2066
|
-
self.transcript:
|
|
2067
|
-
self.retry_count:
|
|
2068
|
-
self.state_history:
|
|
2069
|
-
self.corrections:
|
|
2070
|
-
self.final_message:
|
|
2071
|
-
self.rollback_log:
|
|
2081
|
+
self.state: AgentState = AgentState.IDLE
|
|
2082
|
+
self.plan: dict = {}
|
|
2083
|
+
self.transcript: list = []
|
|
2084
|
+
self.retry_count: int = 0
|
|
2085
|
+
self.state_history: list = []
|
|
2086
|
+
self.corrections: list = []
|
|
2087
|
+
self.final_message: str = ""
|
|
2088
|
+
self.rollback_log: list = []
|
|
2089
|
+
self.executing_model: str | None = None
|
|
2090
|
+
self.reviewing_model: str | None = None
|
|
2091
|
+
|
|
2092
|
+
|
|
2093
|
+
# Pending agent contexts waiting for human approval: context_id → (ctx, req, lang_hint, current_user)
|
|
2094
|
+
_pending_agents: dict[str, tuple] = {}
|
|
2095
|
+
_pending_agents_lock = threading.Lock()
|
|
2072
2096
|
|
|
2073
2097
|
|
|
2074
2098
|
class ToolPathRequest(BaseModel):
|
|
@@ -4323,6 +4347,7 @@ def _extract_agent_action(raw: str) -> Dict:
|
|
|
4323
4347
|
|
|
4324
4348
|
async def _phase_plan(
|
|
4325
4349
|
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str, current_user: str,
|
|
4350
|
+
model_id: str | None = None,
|
|
4326
4351
|
) -> None:
|
|
4327
4352
|
"""PLAN: Planner role produces a structured plan JSON."""
|
|
4328
4353
|
context = (
|
|
@@ -4331,7 +4356,8 @@ async def _phase_plan(
|
|
|
4331
4356
|
f"Workspace root: {AGENT_ROOT}\n\n"
|
|
4332
4357
|
f"User request: {req.message}"
|
|
4333
4358
|
)
|
|
4334
|
-
raw = await router.
|
|
4359
|
+
raw = await router.generate_as(
|
|
4360
|
+
model_id,
|
|
4335
4361
|
message="Produce a JSON execution plan for this request.",
|
|
4336
4362
|
context=context, max_tokens=1024, temperature=0.1,
|
|
4337
4363
|
)
|
|
@@ -4377,7 +4403,7 @@ def _phase_approval(ctx: AgentRunContext, current_user: str) -> None:
|
|
|
4377
4403
|
|
|
4378
4404
|
async def _phase_execute(
|
|
4379
4405
|
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str,
|
|
4380
|
-
current_user: str, max_steps: int,
|
|
4406
|
+
current_user: str, max_steps: int, model_id: str | None = None,
|
|
4381
4407
|
) -> None:
|
|
4382
4408
|
"""EXECUTE: Executor role calls tools one at a time until final or budget exhausted."""
|
|
4383
4409
|
exec_count = sum(1 for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value)
|
|
@@ -4398,7 +4424,8 @@ async def _phase_execute(
|
|
|
4398
4424
|
f"User request: {req.message}{corrections_hint}\n\n"
|
|
4399
4425
|
f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
4400
4426
|
)
|
|
4401
|
-
raw = await router.
|
|
4427
|
+
raw = await router.generate_as(
|
|
4428
|
+
model_id,
|
|
4402
4429
|
message="Execute the next step.",
|
|
4403
4430
|
context=context, max_tokens=4096, temperature=req.temperature,
|
|
4404
4431
|
)
|
|
@@ -4492,7 +4519,7 @@ async def _phase_execute(
|
|
|
4492
4519
|
|
|
4493
4520
|
async def _phase_verify(
|
|
4494
4521
|
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str, current_user: str,
|
|
4495
|
-
max_retry: int = 3,
|
|
4522
|
+
max_retry: int = 3, model_id: str | None = None,
|
|
4496
4523
|
) -> None:
|
|
4497
4524
|
"""VERIFYING: Critic role evaluates transcript → DONE / EXECUTING (retry) / ROLLBACK / FAILED."""
|
|
4498
4525
|
context = (
|
|
@@ -4502,7 +4529,8 @@ async def _phase_verify(
|
|
|
4502
4529
|
f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
|
|
4503
4530
|
f"Full transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
4504
4531
|
)
|
|
4505
|
-
raw = await router.
|
|
4532
|
+
raw = await router.generate_as(
|
|
4533
|
+
model_id,
|
|
4506
4534
|
message="Review the execution transcript and return your verdict JSON.",
|
|
4507
4535
|
context=context, max_tokens=512, temperature=0.1,
|
|
4508
4536
|
)
|
|
@@ -4685,29 +4713,54 @@ async def agent(req: AgentRequest, request: Request):
|
|
|
4685
4713
|
max_retry = 3
|
|
4686
4714
|
|
|
4687
4715
|
ctx = AgentRunContext()
|
|
4716
|
+
ctx.executing_model = req.executing_model
|
|
4717
|
+
ctx.reviewing_model = req.reviewing_model
|
|
4688
4718
|
|
|
4719
|
+
# PLANNING phase
|
|
4720
|
+
ctx.state = AgentState.PLANNING
|
|
4721
|
+
ctx.state_history.append(ctx.state.value)
|
|
4722
|
+
await _phase_plan(ctx, req, router, lang_hint, current_user, model_id=req.planning_model)
|
|
4723
|
+
|
|
4724
|
+
# Human-in-the-loop: pause after planning, return plan to UI
|
|
4725
|
+
if req.human_in_loop:
|
|
4726
|
+
context_id = secrets.token_urlsafe(16)
|
|
4727
|
+
with _pending_agents_lock:
|
|
4728
|
+
_pending_agents[context_id] = (ctx, req, lang_hint, current_user)
|
|
4729
|
+
return {
|
|
4730
|
+
"status": "waiting_approval",
|
|
4731
|
+
"context_id": context_id,
|
|
4732
|
+
"plan": ctx.plan,
|
|
4733
|
+
"steps": ctx.transcript,
|
|
4734
|
+
"state_history": ctx.state_history,
|
|
4735
|
+
"planning_model": req.planning_model or router.current_model_id,
|
|
4736
|
+
"executing_model": req.executing_model or router.current_model_id,
|
|
4737
|
+
"reviewing_model": req.reviewing_model or router.current_model_id,
|
|
4738
|
+
}
|
|
4739
|
+
|
|
4740
|
+
# Auto-approve and run to completion (default behaviour)
|
|
4741
|
+
_phase_approval(ctx, current_user)
|
|
4742
|
+
return await _agent_run_to_completion(ctx, req, router, lang_hint, current_user, max_steps, max_retry)
|
|
4743
|
+
|
|
4744
|
+
|
|
4745
|
+
async def _agent_run_to_completion(
|
|
4746
|
+
ctx: AgentRunContext, req: AgentRequest, router, lang_hint: str,
|
|
4747
|
+
current_user: str, max_steps: int, max_retry: int,
|
|
4748
|
+
) -> dict:
|
|
4749
|
+
"""Run EXECUTING → VERIFYING loop until terminal state."""
|
|
4689
4750
|
while ctx.state not in AGENT_TERMINAL_STATES:
|
|
4690
4751
|
ctx.state_history.append(ctx.state.value)
|
|
4691
|
-
# Hard guard against infinite state loops
|
|
4692
4752
|
if len(ctx.state_history) > 200:
|
|
4693
4753
|
ctx.final_message = "에이전트 상태 머신이 최대 반복(200)에 도달해 중단했습니다."
|
|
4694
4754
|
ctx.state = AgentState.FAILED
|
|
4695
4755
|
break
|
|
4696
4756
|
|
|
4697
|
-
if ctx.state == AgentState.
|
|
4698
|
-
ctx
|
|
4699
|
-
|
|
4700
|
-
elif ctx.state == AgentState.PLANNING:
|
|
4701
|
-
await _phase_plan(ctx, req, router, lang_hint, current_user)
|
|
4702
|
-
|
|
4703
|
-
elif ctx.state == AgentState.WAITING_APPROVAL:
|
|
4704
|
-
_phase_approval(ctx, current_user)
|
|
4705
|
-
|
|
4706
|
-
elif ctx.state == AgentState.EXECUTING:
|
|
4707
|
-
await _phase_execute(ctx, req, router, lang_hint, current_user, max_steps)
|
|
4757
|
+
if ctx.state == AgentState.EXECUTING:
|
|
4758
|
+
await _phase_execute(ctx, req, router, lang_hint, current_user, max_steps,
|
|
4759
|
+
model_id=ctx.executing_model)
|
|
4708
4760
|
|
|
4709
4761
|
elif ctx.state == AgentState.VERIFYING:
|
|
4710
|
-
await _phase_verify(ctx, req, router, lang_hint, current_user, max_retry
|
|
4762
|
+
await _phase_verify(ctx, req, router, lang_hint, current_user, max_retry,
|
|
4763
|
+
model_id=ctx.reviewing_model)
|
|
4711
4764
|
|
|
4712
4765
|
elif ctx.state == AgentState.ROLLBACK:
|
|
4713
4766
|
_phase_rollback(ctx, current_user)
|
|
@@ -4715,10 +4768,7 @@ async def agent(req: AgentRequest, request: Request):
|
|
|
4715
4768
|
else:
|
|
4716
4769
|
ctx.state = AgentState.FAILED
|
|
4717
4770
|
|
|
4718
|
-
# Record terminal state in history for clients
|
|
4719
4771
|
ctx.state_history.append(ctx.state.value)
|
|
4720
|
-
|
|
4721
|
-
# Fire-and-forget memory update — does not block the response
|
|
4722
4772
|
asyncio.create_task(_phase_memory_update(ctx, req, router, current_user))
|
|
4723
4773
|
|
|
4724
4774
|
message = ctx.final_message or "작업을 완료했습니다."
|
|
@@ -4736,6 +4786,36 @@ async def agent(req: AgentRequest, request: Request):
|
|
|
4736
4786
|
}
|
|
4737
4787
|
|
|
4738
4788
|
|
|
4789
|
+
@app.post("/agent/resume")
|
|
4790
|
+
async def agent_resume(req: AgentResumeRequest, request: Request):
|
|
4791
|
+
"""Resume a paused agent after human approval of the plan."""
|
|
4792
|
+
current_user = require_user(request)
|
|
4793
|
+
|
|
4794
|
+
with _pending_agents_lock:
|
|
4795
|
+
entry = _pending_agents.pop(req.context_id, None)
|
|
4796
|
+
if not entry:
|
|
4797
|
+
raise HTTPException(status_code=404, detail="Agent context not found or expired. Start a new request.")
|
|
4798
|
+
|
|
4799
|
+
ctx, orig_req, lang_hint, _orig_user = entry
|
|
4800
|
+
|
|
4801
|
+
if not req.approved:
|
|
4802
|
+
return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
|
|
4803
|
+
|
|
4804
|
+
if req.modified_plan:
|
|
4805
|
+
ctx.plan = req.modified_plan
|
|
4806
|
+
ctx.transcript[-1].update(ctx.plan) # keep transcript in sync
|
|
4807
|
+
|
|
4808
|
+
# Apply model overrides from resume request (takes priority over original request)
|
|
4809
|
+
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
4810
|
+
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
4811
|
+
|
|
4812
|
+
_phase_approval(ctx, current_user)
|
|
4813
|
+
|
|
4814
|
+
max_steps = max(1, min(orig_req.max_steps, 50))
|
|
4815
|
+
max_retry = 3
|
|
4816
|
+
return await _agent_run_to_completion(ctx, orig_req, router, lang_hint, current_user, max_steps, max_retry)
|
|
4817
|
+
|
|
4818
|
+
|
|
4739
4819
|
# ── Direct Tool API ───────────────────────────────────────────────────────────
|
|
4740
4820
|
|
|
4741
4821
|
def _tool_response(fn, *args):
|
package/static/chat.html
CHANGED
|
@@ -1048,6 +1048,47 @@
|
|
|
1048
1048
|
background: rgba(34,211,160,0.07);
|
|
1049
1049
|
}
|
|
1050
1050
|
|
|
1051
|
+
/* ── 멀티 LLM 파이프라인 ── */
|
|
1052
|
+
.pipeline-phase-row {
|
|
1053
|
+
display: flex; flex-direction: column; gap: 8px;
|
|
1054
|
+
background: rgba(255,255,255,0.02);
|
|
1055
|
+
border: 1px solid var(--border);
|
|
1056
|
+
border-radius: 10px; padding: 12px;
|
|
1057
|
+
}
|
|
1058
|
+
.pipeline-phase-label { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
1059
|
+
.pipeline-phase-badge {
|
|
1060
|
+
font-size: 12px; font-weight: 600; padding: 3px 10px;
|
|
1061
|
+
border-radius: 20px; white-space: nowrap;
|
|
1062
|
+
}
|
|
1063
|
+
.pipeline-select {
|
|
1064
|
+
width: 100%; background: var(--surface); border: 1px solid var(--border);
|
|
1065
|
+
color: var(--text); padding: 8px 10px; border-radius: 8px;
|
|
1066
|
+
font-size: 13px; cursor: pointer; outline: none;
|
|
1067
|
+
}
|
|
1068
|
+
.pipeline-select:focus { border-color: var(--accent); }
|
|
1069
|
+
.pipeline-active-badge {
|
|
1070
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
1071
|
+
font-size: 11px; background: rgba(34,211,160,0.1);
|
|
1072
|
+
border: 1px solid rgba(34,211,160,0.25); color: var(--accent);
|
|
1073
|
+
padding: 2px 8px; border-radius: 20px; margin-left: 6px; cursor: pointer;
|
|
1074
|
+
}
|
|
1075
|
+
.plan-approval-card {
|
|
1076
|
+
background: rgba(99,102,241,0.07); border: 1px solid rgba(99,102,241,0.2);
|
|
1077
|
+
border-radius: 12px; padding: 16px; margin-top: 4px;
|
|
1078
|
+
}
|
|
1079
|
+
.plan-approval-card h4 { margin: 0 0 10px; color: #818cf8; font-size: 14px; }
|
|
1080
|
+
.plan-approval-card ol { margin: 0 0 12px; padding-left: 20px; color: var(--text); font-size: 13px; line-height: 1.8; }
|
|
1081
|
+
.plan-approval-card .plan-meta { font-size: 11px; color: var(--muted); margin-bottom: 12px; }
|
|
1082
|
+
.plan-approval-actions { display: flex; gap: 8px; }
|
|
1083
|
+
.plan-approve-btn {
|
|
1084
|
+
flex: 1; background: var(--accent); border: none; color: #05120d;
|
|
1085
|
+
padding: 9px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer;
|
|
1086
|
+
}
|
|
1087
|
+
.plan-cancel-btn {
|
|
1088
|
+
background: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.25);
|
|
1089
|
+
color: var(--danger); padding: 9px 16px; border-radius: 8px; font-size: 13px; cursor: pointer;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1051
1092
|
/* ── 첨부 파일 칩 ── */
|
|
1052
1093
|
.attach-chip {
|
|
1053
1094
|
display: inline-flex;
|
|
@@ -3134,6 +3175,14 @@
|
|
|
3134
3175
|
</div>
|
|
3135
3176
|
<div class="ops-icon"><i class="ti ti-cpu-2"></i></div>
|
|
3136
3177
|
</div>
|
|
3178
|
+
<div class="ops-card interactive" id="pipeline-ops-card" onclick="openPipelineModal()">
|
|
3179
|
+
<div>
|
|
3180
|
+
<div class="ops-label">PIPELINE</div>
|
|
3181
|
+
<div id="ops-pipeline-value" class="ops-value">멀티 LLM 파이프라인</div>
|
|
3182
|
+
<div id="ops-pipeline-meta" class="ops-meta">Plan → Execute → Review 모델 설정</div>
|
|
3183
|
+
</div>
|
|
3184
|
+
<div class="ops-icon"><i class="ti ti-git-branch"></i></div>
|
|
3185
|
+
</div>
|
|
3137
3186
|
<div class="ops-card interactive" onclick="openVpcPanel()">
|
|
3138
3187
|
<div>
|
|
3139
3188
|
<div class="ops-label">PRIVATE VPC</div>
|
|
@@ -3226,6 +3275,65 @@
|
|
|
3226
3275
|
</div>
|
|
3227
3276
|
</div>
|
|
3228
3277
|
|
|
3278
|
+
<!-- ── 멀티 LLM 파이프라인 모달 ── -->
|
|
3279
|
+
<div id="pipeline-overlay" class="admin-overlay" style="display:none" onclick="if(event.target===this)closePipelineModal()">
|
|
3280
|
+
<section class="admin-panel" style="max-width:520px">
|
|
3281
|
+
<div class="model-panel-header">
|
|
3282
|
+
<div>
|
|
3283
|
+
<h2><i class="ti ti-git-branch" style="color:var(--accent)"></i> 멀티 LLM 파이프라인</h2>
|
|
3284
|
+
<p style="color:var(--muted);font-size:12px;margin-top:4px">각 단계에 사용할 LLM을 지정합니다. 동일 모델을 여러 단계에 써도 됩니다.</p>
|
|
3285
|
+
</div>
|
|
3286
|
+
<button class="admin-close" onclick="closePipelineModal()"><i class="ti ti-x"></i></button>
|
|
3287
|
+
</div>
|
|
3288
|
+
|
|
3289
|
+
<div style="display:flex;flex-direction:column;gap:16px;padding:0 4px 4px">
|
|
3290
|
+
<!-- Planning -->
|
|
3291
|
+
<div class="pipeline-phase-row">
|
|
3292
|
+
<div class="pipeline-phase-label">
|
|
3293
|
+
<span class="pipeline-phase-badge" style="background:rgba(99,102,241,0.15);color:#818cf8">📋 Planning</span>
|
|
3294
|
+
<span style="color:var(--muted);font-size:11px">계획 수립 · 유저와 함께 검토</span>
|
|
3295
|
+
</div>
|
|
3296
|
+
<select id="pipeline-planning-select" class="pipeline-select">
|
|
3297
|
+
<option value="">현재 로드된 모델 (기본)</option>
|
|
3298
|
+
</select>
|
|
3299
|
+
</div>
|
|
3300
|
+
|
|
3301
|
+
<!-- Executing -->
|
|
3302
|
+
<div class="pipeline-phase-row">
|
|
3303
|
+
<div class="pipeline-phase-label">
|
|
3304
|
+
<span class="pipeline-phase-badge" style="background:rgba(34,197,94,0.12);color:#4ade80">⚙️ Executing</span>
|
|
3305
|
+
<span style="color:var(--muted);font-size:11px">코드 작성 · 파일 생성 · 툴 호출</span>
|
|
3306
|
+
</div>
|
|
3307
|
+
<select id="pipeline-executing-select" class="pipeline-select">
|
|
3308
|
+
<option value="">현재 로드된 모델 (기본)</option>
|
|
3309
|
+
</select>
|
|
3310
|
+
</div>
|
|
3311
|
+
|
|
3312
|
+
<!-- Reviewing -->
|
|
3313
|
+
<div class="pipeline-phase-row">
|
|
3314
|
+
<div class="pipeline-phase-label">
|
|
3315
|
+
<span class="pipeline-phase-badge" style="background:rgba(251,146,60,0.12);color:#fb923c">🔍 Reviewing</span>
|
|
3316
|
+
<span style="color:var(--muted);font-size:11px">결과 검증 · 최종 답변 생성</span>
|
|
3317
|
+
</div>
|
|
3318
|
+
<select id="pipeline-reviewing-select" class="pipeline-select">
|
|
3319
|
+
<option value="">현재 로드된 모델 (기본)</option>
|
|
3320
|
+
</select>
|
|
3321
|
+
</div>
|
|
3322
|
+
|
|
3323
|
+
<div style="background:rgba(99,102,241,0.07);border:1px solid rgba(99,102,241,0.15);border-radius:10px;padding:12px;font-size:12px;color:var(--muted)">
|
|
3324
|
+
<i class="ti ti-info-circle" style="color:var(--accent)"></i>
|
|
3325
|
+
모달을 닫고 채팅창에 작업을 입력하면 <b style="color:var(--text)">Pipeline 모드</b>로 실행됩니다.<br>
|
|
3326
|
+
계획이 완성되면 UI에서 검토 후 <b style="color:var(--accent)">✅ Done</b>을 눌러야 실행이 시작됩니다.
|
|
3327
|
+
</div>
|
|
3328
|
+
|
|
3329
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
3330
|
+
<button onclick="resetPipeline()" style="background:none;border:1px solid var(--border);color:var(--muted);padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px">초기화</button>
|
|
3331
|
+
<button onclick="savePipelineAndClose()" style="background:var(--accent);border:none;color:#05120d;padding:8px 20px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600"><i class="ti ti-check"></i> 저장</button>
|
|
3332
|
+
</div>
|
|
3333
|
+
</div>
|
|
3334
|
+
</section>
|
|
3335
|
+
</div>
|
|
3336
|
+
|
|
3229
3337
|
<!-- ── 파일 생성 패널 ── -->
|
|
3230
3338
|
<div id="file-create-overlay" class="admin-overlay" style="display:none">
|
|
3231
3339
|
<section class="admin-panel" style="max-width:480px">
|
|
@@ -3542,6 +3650,148 @@
|
|
|
3542
3650
|
let currentUserNickname = "Guest";
|
|
3543
3651
|
let currentUserEmail = "";
|
|
3544
3652
|
let isAdmin = false;
|
|
3653
|
+
|
|
3654
|
+
// ── 멀티 LLM 파이프라인 상태 ──
|
|
3655
|
+
let pipelineConfig = { planning: null, executing: null, reviewing: null };
|
|
3656
|
+
let pipelineActive = false; // true이면 전송 시 pipeline 모드
|
|
3657
|
+
|
|
3658
|
+
function openPipelineModal() {
|
|
3659
|
+
document.getElementById('pipeline-overlay').style.display = 'flex';
|
|
3660
|
+
loadPipelineModelOptions();
|
|
3661
|
+
}
|
|
3662
|
+
function closePipelineModal() {
|
|
3663
|
+
document.getElementById('pipeline-overlay').style.display = 'none';
|
|
3664
|
+
}
|
|
3665
|
+
function resetPipeline() {
|
|
3666
|
+
pipelineConfig = { planning: null, executing: null, reviewing: null };
|
|
3667
|
+
pipelineActive = false;
|
|
3668
|
+
['planning','executing','reviewing'].forEach(p =>
|
|
3669
|
+
document.getElementById(`pipeline-${p}-select`).value = '');
|
|
3670
|
+
updatePipelineBadge();
|
|
3671
|
+
}
|
|
3672
|
+
function savePipelineAndClose() {
|
|
3673
|
+
pipelineConfig = {
|
|
3674
|
+
planning: document.getElementById('pipeline-planning-select').value || null,
|
|
3675
|
+
executing: document.getElementById('pipeline-executing-select').value || null,
|
|
3676
|
+
reviewing: document.getElementById('pipeline-reviewing-select').value || null,
|
|
3677
|
+
};
|
|
3678
|
+
pipelineActive = true;
|
|
3679
|
+
updatePipelineBadge();
|
|
3680
|
+
closePipelineModal();
|
|
3681
|
+
}
|
|
3682
|
+
function updatePipelineBadge() {
|
|
3683
|
+
const card = document.getElementById('pipeline-ops-card');
|
|
3684
|
+
const val = document.getElementById('ops-pipeline-value');
|
|
3685
|
+
const meta = document.getElementById('ops-pipeline-meta');
|
|
3686
|
+
if (!card) return;
|
|
3687
|
+
if (pipelineActive) {
|
|
3688
|
+
card.style.borderColor = 'rgba(34,211,160,0.35)';
|
|
3689
|
+
card.style.boxShadow = '0 0 0 1px rgba(34,211,160,0.18)';
|
|
3690
|
+
const pLabel = pipelineConfig.planning ? pipelineConfig.planning.split('/').pop() : '—';
|
|
3691
|
+
const eLabel = pipelineConfig.executing ? pipelineConfig.executing.split('/').pop() : '—';
|
|
3692
|
+
const rLabel = pipelineConfig.reviewing ? pipelineConfig.reviewing.split('/').pop() : '—';
|
|
3693
|
+
if (val) val.textContent = 'Pipeline ON';
|
|
3694
|
+
if (meta) meta.textContent = `P:${pLabel} E:${eLabel} R:${rLabel}`;
|
|
3695
|
+
} else {
|
|
3696
|
+
card.style.borderColor = '';
|
|
3697
|
+
card.style.boxShadow = '';
|
|
3698
|
+
if (val) val.textContent = '멀티 LLM 파이프라인';
|
|
3699
|
+
if (meta) meta.textContent = 'Plan → Execute → Review 모델 설정';
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
async function loadPipelineModelOptions() {
|
|
3703
|
+
let models = [];
|
|
3704
|
+
try {
|
|
3705
|
+
const res = await apiFetch('/models');
|
|
3706
|
+
const data = await res.json();
|
|
3707
|
+
const loaded = data.loaded || [];
|
|
3708
|
+
const cloud = data.providers || [];
|
|
3709
|
+
loaded.forEach(m => models.push({ id: m, label: `[로컬] ${m.split('/').pop()}` }));
|
|
3710
|
+
cloud.forEach(m => {
|
|
3711
|
+
if (m.available !== false)
|
|
3712
|
+
models.push({ id: m.id || m.model_id, label: `[클라우드] ${m.name || m.id}` });
|
|
3713
|
+
});
|
|
3714
|
+
} catch(e) { /* silent */ }
|
|
3715
|
+
['planning','executing','reviewing'].forEach(phase => {
|
|
3716
|
+
const sel = document.getElementById(`pipeline-${phase}-select`);
|
|
3717
|
+
const cur = pipelineConfig[phase] || '';
|
|
3718
|
+
sel.innerHTML = '<option value="">현재 로드된 모델 (기본)</option>';
|
|
3719
|
+
models.forEach(m => {
|
|
3720
|
+
const opt = document.createElement('option');
|
|
3721
|
+
opt.value = m.id; opt.textContent = m.label;
|
|
3722
|
+
if (m.id === cur) opt.selected = true;
|
|
3723
|
+
sel.appendChild(opt);
|
|
3724
|
+
});
|
|
3725
|
+
});
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
// ── 플랜 승인 카드 렌더링 ──
|
|
3729
|
+
async function renderPlanApprovalCard(bubble, data) {
|
|
3730
|
+
const plan = data.plan || {};
|
|
3731
|
+
const steps = plan.steps || [];
|
|
3732
|
+
const pM = data.planning_model || '현재 모델';
|
|
3733
|
+
const eM = data.executing_model || '현재 모델';
|
|
3734
|
+
const rM = data.reviewing_model || '현재 모델';
|
|
3735
|
+
const contextId = data.context_id;
|
|
3736
|
+
|
|
3737
|
+
let stepsHtml = steps.map((s,i) =>
|
|
3738
|
+
`<li>${escapeHtml(s.description || s.action || JSON.stringify(s))}</li>`).join('');
|
|
3739
|
+
|
|
3740
|
+
bubble.innerHTML = `
|
|
3741
|
+
<div class="plan-approval-card">
|
|
3742
|
+
<h4>📋 플래닝 완료 — 실행 전 확인해주세요</h4>
|
|
3743
|
+
${plan.goal ? `<p style="color:var(--text);font-size:13px;margin:0 0 10px"><b>목표:</b> ${escapeHtml(plan.goal)}</p>` : ''}
|
|
3744
|
+
<ol>${stepsHtml || '<li>(단계 없음)</li>'}</ol>
|
|
3745
|
+
<div class="plan-meta">
|
|
3746
|
+
🧠 Planning: <b>${escapeHtml(compactModelName(pM))}</b> ·
|
|
3747
|
+
⚙️ Executing: <b>${escapeHtml(compactModelName(eM))}</b> ·
|
|
3748
|
+
🔍 Reviewing: <b>${escapeHtml(compactModelName(rM))}</b>
|
|
3749
|
+
</div>
|
|
3750
|
+
<div class="plan-approval-actions">
|
|
3751
|
+
<button class="plan-approve-btn" onclick="resumeAgent('${contextId}', this)">
|
|
3752
|
+
<i class="ti ti-player-play"></i> ✅ Done — 실행 시작
|
|
3753
|
+
</button>
|
|
3754
|
+
<button class="plan-cancel-btn" onclick="cancelAgent('${contextId}', this)">❌ 취소</button>
|
|
3755
|
+
</div>
|
|
3756
|
+
</div>`;
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
async function resumeAgent(contextId, btn) {
|
|
3760
|
+
btn.disabled = true;
|
|
3761
|
+
btn.textContent = '⚙️ 실행 중...';
|
|
3762
|
+
const card = btn.closest('.plan-approval-card');
|
|
3763
|
+
try {
|
|
3764
|
+
const res = await apiFetch('/agent/resume', {
|
|
3765
|
+
method: 'POST',
|
|
3766
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3767
|
+
body: JSON.stringify({
|
|
3768
|
+
context_id: contextId,
|
|
3769
|
+
approved: true,
|
|
3770
|
+
executing_model: pipelineConfig.executing || null,
|
|
3771
|
+
reviewing_model: pipelineConfig.reviewing || null,
|
|
3772
|
+
})
|
|
3773
|
+
});
|
|
3774
|
+
const data = await res.json();
|
|
3775
|
+
if (!res.ok) throw new Error(data.detail || `서버 오류 (${res.status})`);
|
|
3776
|
+
const bubble = btn.closest('.bubble');
|
|
3777
|
+
renderAiBubble(bubble, data.response || '완료되었습니다.');
|
|
3778
|
+
const files = data.created_files || [];
|
|
3779
|
+
files.forEach(f => renderFileDownloadCard(f.filename, f.path, f.bytes || 0));
|
|
3780
|
+
} catch(e) {
|
|
3781
|
+
card.innerHTML += `<p style="color:var(--danger);font-size:12px;margin-top:8px">❌ ${escapeHtml(e.message)}</p>`;
|
|
3782
|
+
btn.disabled = false;
|
|
3783
|
+
btn.textContent = '다시 시도';
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
async function cancelAgent(contextId, btn) {
|
|
3788
|
+
await apiFetch('/agent/resume', {
|
|
3789
|
+
method: 'POST',
|
|
3790
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3791
|
+
body: JSON.stringify({ context_id: contextId, approved: false })
|
|
3792
|
+
}).catch(()=>{});
|
|
3793
|
+
btn.closest('.bubble').innerHTML = '<span style="color:var(--muted)">작업이 취소되었습니다.</span>';
|
|
3794
|
+
}
|
|
3545
3795
|
let latestVpcConfig = null;
|
|
3546
3796
|
const mirroredHistoryKeys = new Set();
|
|
3547
3797
|
const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
|
|
@@ -5577,7 +5827,10 @@
|
|
|
5577
5827
|
sendBtn.disabled = true;
|
|
5578
5828
|
const aiMsgDiv = document.createElement('div');
|
|
5579
5829
|
aiMsgDiv.className = 'message ai';
|
|
5580
|
-
|
|
5830
|
+
const modeLabel = pipelineActive
|
|
5831
|
+
? '⚙ 파이프라인 모드'
|
|
5832
|
+
: '⚙ 에이전트 모드';
|
|
5833
|
+
aiMsgDiv.innerHTML = `<div class="sender-label">Lattice AI <span style="color:var(--accent);font-size:11px">${modeLabel}</span></div><div class="bubble">${pipelineActive ? '📋 계획 수립 중입니다...' : '파일을 생성하고 있습니다...'}</div>`;
|
|
5581
5834
|
chatViewport.appendChild(aiMsgDiv);
|
|
5582
5835
|
chatViewport.scrollTop = chatViewport.scrollHeight;
|
|
5583
5836
|
const bubble = aiMsgDiv.querySelector('.bubble');
|
|
@@ -5593,7 +5846,11 @@
|
|
|
5593
5846
|
max_steps: 4,
|
|
5594
5847
|
temperature: 0.1,
|
|
5595
5848
|
user_email: currentUserEmail,
|
|
5596
|
-
user_nickname: currentUserNickname
|
|
5849
|
+
user_nickname: currentUserNickname,
|
|
5850
|
+
human_in_loop: pipelineActive,
|
|
5851
|
+
planning_model: pipelineActive ? (pipelineConfig.planning || null) : null,
|
|
5852
|
+
executing_model: pipelineActive ? (pipelineConfig.executing || null) : null,
|
|
5853
|
+
reviewing_model: pipelineActive ? (pipelineConfig.reviewing || null) : null,
|
|
5597
5854
|
})
|
|
5598
5855
|
});
|
|
5599
5856
|
|
|
@@ -5602,6 +5859,13 @@
|
|
|
5602
5859
|
|
|
5603
5860
|
if (!res.ok) throw new Error(data.detail || `서버 오류 (${res.status})`);
|
|
5604
5861
|
|
|
5862
|
+
// Pipeline mode: show plan for approval
|
|
5863
|
+
if (data.status === 'waiting_approval') {
|
|
5864
|
+
await renderPlanApprovalCard(bubble, data);
|
|
5865
|
+
loadHistory();
|
|
5866
|
+
return;
|
|
5867
|
+
}
|
|
5868
|
+
|
|
5605
5869
|
renderAiBubble(bubble, data.response || '완료되었습니다.');
|
|
5606
5870
|
|
|
5607
5871
|
const files = data.created_files || [];
|
package/telegram_bot.py
CHANGED
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
import socket
|
|
7
7
|
import subprocess
|
|
8
8
|
import tempfile
|
|
9
|
+
import time
|
|
9
10
|
import zipfile
|
|
10
11
|
import json
|
|
11
12
|
from pathlib import Path
|
|
@@ -38,7 +39,11 @@ MODELS_URL = f"{BASE_URL}/models"
|
|
|
38
39
|
GRAPH_STATS_URL = f"{BASE_URL}/knowledge-graph/stats"
|
|
39
40
|
UPLOAD_DOC_URL = f"{BASE_URL}/upload/document"
|
|
40
41
|
|
|
42
|
+
AGENT_RESUME_URL = f"{BASE_URL}/agent/resume"
|
|
41
43
|
AGENT_WORKSPACE = Path(env_value("LATTICEAI_AGENT_ROOT", "agent_workspace")).resolve()
|
|
44
|
+
|
|
45
|
+
# Pending plan approvals: context_id → (chat_id, executing_model, reviewing_model)
|
|
46
|
+
_bot_pending_plans: dict[str, dict] = {}
|
|
42
47
|
MAX_TELEGRAM_FILE_BYTES = 45 * 1024 * 1024
|
|
43
48
|
SERVER_PORT = int(env_value("LATTICEAI_SERVER_PORT", "4825"))
|
|
44
49
|
INVITE_CODE = env_value("LATTICEAI_INVITE_CODE", "gemma-lattice-ai")
|
|
@@ -50,6 +55,41 @@ CHAT_IDS_FILE = Path(env_value("LATTICEAI_TELEGRAM_CHATS_FILE", str(DATA_DIR / "
|
|
|
50
55
|
logging.basicConfig(level=logging.INFO)
|
|
51
56
|
logger = logging.getLogger(__name__)
|
|
52
57
|
|
|
58
|
+
# ── Server session auth ───────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def _get_server_session() -> str:
|
|
61
|
+
"""Read the most recent valid admin session from sessions.json (web login)."""
|
|
62
|
+
sessions_file = DATA_DIR / "sessions.json"
|
|
63
|
+
users_file = DATA_DIR / "users.json"
|
|
64
|
+
try:
|
|
65
|
+
if not sessions_file.exists():
|
|
66
|
+
return ""
|
|
67
|
+
sessions = json.loads(sessions_file.read_text())
|
|
68
|
+
admin_emails: set[str] = set()
|
|
69
|
+
if users_file.exists():
|
|
70
|
+
users = json.loads(users_file.read_text())
|
|
71
|
+
admin_emails = {e for e, u in users.items() if u.get("role") == "admin" and not u.get("disabled")}
|
|
72
|
+
now = time.time()
|
|
73
|
+
# Pick the newest non-expired admin session
|
|
74
|
+
best_token, best_ts = "", 0.0
|
|
75
|
+
for token, entry in sessions.items():
|
|
76
|
+
email, created_at = entry[0], float(entry[1])
|
|
77
|
+
if admin_emails and email not in admin_emails:
|
|
78
|
+
continue
|
|
79
|
+
if now - created_at > 7 * 86400:
|
|
80
|
+
continue
|
|
81
|
+
if created_at > best_ts:
|
|
82
|
+
best_token, best_ts = token, created_at
|
|
83
|
+
return best_token
|
|
84
|
+
except Exception:
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
def _server_client(**kwargs) -> httpx.AsyncClient:
|
|
88
|
+
"""httpx client pre-loaded with the web session cookie."""
|
|
89
|
+
token = _get_server_session()
|
|
90
|
+
cookies = {"session_token": token} if token else {}
|
|
91
|
+
return httpx.AsyncClient(cookies=cookies, **kwargs)
|
|
92
|
+
|
|
53
93
|
# ── Chat ID registry ─────────────────────────────────────────────────────────
|
|
54
94
|
|
|
55
95
|
def load_chat_ids():
|
|
@@ -281,7 +321,7 @@ async def _mac_ram_used_gb() -> str:
|
|
|
281
321
|
async def show_status(client, chat_id):
|
|
282
322
|
await send_chat_action(client, chat_id, "typing")
|
|
283
323
|
try:
|
|
284
|
-
async with
|
|
324
|
+
async with _server_client() as lc:
|
|
285
325
|
res = await lc.get(STATUS_URL, timeout=5.0)
|
|
286
326
|
data = res.json() if res.status_code == 200 else {}
|
|
287
327
|
except Exception:
|
|
@@ -306,7 +346,7 @@ async def show_status(client, chat_id):
|
|
|
306
346
|
async def show_model_info(client, chat_id):
|
|
307
347
|
await send_chat_action(client, chat_id, "typing")
|
|
308
348
|
try:
|
|
309
|
-
async with
|
|
349
|
+
async with _server_client() as lc:
|
|
310
350
|
res = await lc.get(MODELS_URL, timeout=5.0)
|
|
311
351
|
data = res.json() if res.status_code == 200 else {}
|
|
312
352
|
except Exception:
|
|
@@ -330,7 +370,7 @@ async def show_model_info(client, chat_id):
|
|
|
330
370
|
async def do_unload_model(client, chat_id, model_id: str = ""):
|
|
331
371
|
await send_chat_action(client, chat_id, "typing")
|
|
332
372
|
try:
|
|
333
|
-
async with
|
|
373
|
+
async with _server_client() as lc:
|
|
334
374
|
if model_id:
|
|
335
375
|
res = await lc.delete(f"{BASE_URL}/models/unload/{model_id}", timeout=15.0)
|
|
336
376
|
else:
|
|
@@ -353,7 +393,7 @@ async def do_unload_model(client, chat_id, model_id: str = ""):
|
|
|
353
393
|
async def show_graph_stats(client, chat_id):
|
|
354
394
|
await send_chat_action(client, chat_id, "typing")
|
|
355
395
|
try:
|
|
356
|
-
async with
|
|
396
|
+
async with _server_client() as lc:
|
|
357
397
|
res = await lc.get(GRAPH_STATS_URL, timeout=5.0)
|
|
358
398
|
data = res.json() if res.status_code == 200 else {}
|
|
359
399
|
except Exception:
|
|
@@ -414,7 +454,7 @@ async def take_screenshot(client, chat_id):
|
|
|
414
454
|
async def show_history_summary(client, chat_id, n: int = 5):
|
|
415
455
|
await send_chat_action(client, chat_id, "typing")
|
|
416
456
|
try:
|
|
417
|
-
async with
|
|
457
|
+
async with _server_client() as lc:
|
|
418
458
|
res = await lc.get(HISTORY_URL, timeout=10.0)
|
|
419
459
|
items = res.json() if res.status_code == 200 else []
|
|
420
460
|
except Exception:
|
|
@@ -435,7 +475,7 @@ async def show_history_summary(client, chat_id, n: int = 5):
|
|
|
435
475
|
|
|
436
476
|
async def clear_server_history(client, chat_id, keep_last=0):
|
|
437
477
|
try:
|
|
438
|
-
async with
|
|
478
|
+
async with _server_client() as lc:
|
|
439
479
|
res = await lc.delete(HISTORY_URL, params={"keep_last": keep_last}, timeout=10.0)
|
|
440
480
|
data = res.json() if res.headers.get("content-type", "").startswith("application/json") else {}
|
|
441
481
|
if res.status_code == 200:
|
|
@@ -466,7 +506,7 @@ async def send_web_link(client, chat_id):
|
|
|
466
506
|
},
|
|
467
507
|
}
|
|
468
508
|
try:
|
|
469
|
-
async with
|
|
509
|
+
async with _server_client() as lc:
|
|
470
510
|
await lc.post(f"{API_URL}/sendMessage", json=payload)
|
|
471
511
|
except Exception as e:
|
|
472
512
|
logger.error("웹 링크 전송 실패: %s", e)
|
|
@@ -475,7 +515,7 @@ async def send_web_link(client, chat_id):
|
|
|
475
515
|
|
|
476
516
|
async def send_mcp_tools(client, chat_id):
|
|
477
517
|
try:
|
|
478
|
-
async with
|
|
518
|
+
async with _server_client() as lc:
|
|
479
519
|
res = await lc.get(MCP_TOOLS_URL, timeout=10.0)
|
|
480
520
|
if res.status_code != 200:
|
|
481
521
|
await send_message(client, chat_id, f"MCP 도구 목록을 가져오지 못했습니다: {res.status_code}")
|
|
@@ -506,7 +546,7 @@ async def process_document_file(client, chat_id, file_id: str, filename: str, ca
|
|
|
506
546
|
tmp = Path(tempfile.mktemp(suffix=suffix))
|
|
507
547
|
try:
|
|
508
548
|
tmp.write_bytes(raw)
|
|
509
|
-
async with
|
|
549
|
+
async with _server_client() as lc:
|
|
510
550
|
with open(tmp, "rb") as f:
|
|
511
551
|
res = await lc.post(
|
|
512
552
|
UPLOAD_DOC_URL,
|
|
@@ -536,15 +576,37 @@ async def process_document_file(client, chat_id, file_id: str, filename: str, ca
|
|
|
536
576
|
|
|
537
577
|
# ── AI chat ───────────────────────────────────────────────────────────────────
|
|
538
578
|
|
|
539
|
-
async def ask_ai(client, message, image_data=None, agent_mode=
|
|
579
|
+
async def ask_ai(client, message, image_data=None, agent_mode=False,
|
|
580
|
+
planning_model=None, executing_model=None, reviewing_model=None):
|
|
540
581
|
try:
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
582
|
+
if agent_mode and not image_data:
|
|
583
|
+
url = AGENT_URL
|
|
584
|
+
payload = {
|
|
585
|
+
"message": message, "source": "telegram",
|
|
586
|
+
"human_in_loop": True,
|
|
587
|
+
"planning_model": planning_model,
|
|
588
|
+
"executing_model": executing_model,
|
|
589
|
+
"reviewing_model": reviewing_model,
|
|
590
|
+
}
|
|
591
|
+
else:
|
|
592
|
+
url = CHAT_URL
|
|
593
|
+
payload = {"message": message, "source": "telegram", "stream": False}
|
|
594
|
+
if image_data:
|
|
595
|
+
payload["image_data"] = image_data
|
|
596
|
+
async with _server_client() as sc:
|
|
597
|
+
res = await sc.post(url, json=payload, timeout=300.0)
|
|
547
598
|
if res.status_code == 200:
|
|
599
|
+
ct = res.headers.get("content-type", "")
|
|
600
|
+
if "text/event-stream" in ct:
|
|
601
|
+
text = ""
|
|
602
|
+
for line in res.text.splitlines():
|
|
603
|
+
if line.startswith("data:"):
|
|
604
|
+
try:
|
|
605
|
+
chunk = json.loads(line[5:].strip()).get("chunk", "")
|
|
606
|
+
text += chunk
|
|
607
|
+
except Exception:
|
|
608
|
+
pass
|
|
609
|
+
return {"response": text.strip() or "⚠️ 빈 응답"}
|
|
548
610
|
return res.json()
|
|
549
611
|
try:
|
|
550
612
|
detail = res.json().get("detail", "")
|
|
@@ -625,12 +687,88 @@ async def send_generated_files(client, chat_id, generated_files):
|
|
|
625
687
|
finally:
|
|
626
688
|
zip_path.unlink(missing_ok=True)
|
|
627
689
|
|
|
690
|
+
# ── Plan approval (Human-in-the-loop) ────────────────────────────────────────
|
|
691
|
+
|
|
692
|
+
async def send_plan_for_approval(client, chat_id, data: dict) -> None:
|
|
693
|
+
"""Show the agent plan to the user and present Done/Cancel buttons."""
|
|
694
|
+
context_id = data.get("context_id", "")
|
|
695
|
+
plan = data.get("plan", {})
|
|
696
|
+
goal = plan.get("goal", "")
|
|
697
|
+
steps = plan.get("steps", [])
|
|
698
|
+
p_model = data.get("planning_model", "current")
|
|
699
|
+
e_model = data.get("executing_model", "current")
|
|
700
|
+
r_model = data.get("reviewing_model", "current")
|
|
701
|
+
|
|
702
|
+
lines = [f"📋 *플래닝 완료* — 실행 전 확인해주세요\n"]
|
|
703
|
+
if goal:
|
|
704
|
+
lines.append(f"*목표:* {goal}\n")
|
|
705
|
+
for i, step in enumerate(steps, 1):
|
|
706
|
+
desc = step.get("description") or step.get("action") or str(step)
|
|
707
|
+
lines.append(f"{i}. {desc}")
|
|
708
|
+
lines.append(f"\n🧠 플래닝: `{p_model}`")
|
|
709
|
+
lines.append(f"⚙️ 실행: `{e_model}`")
|
|
710
|
+
lines.append(f"🔍 검토: `{r_model}`")
|
|
711
|
+
|
|
712
|
+
_bot_pending_plans[context_id] = {
|
|
713
|
+
"chat_id": chat_id,
|
|
714
|
+
"executing_model": data.get("executing_model"),
|
|
715
|
+
"reviewing_model": data.get("reviewing_model"),
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
keyboard = {"inline_keyboard": [[
|
|
719
|
+
{"text": "✅ Done — 실행 시작", "callback_data": f"plan:approve:{context_id}"},
|
|
720
|
+
{"text": "❌ 취소", "callback_data": f"plan:cancel:{context_id}"},
|
|
721
|
+
]]}
|
|
722
|
+
await send_message(client, chat_id, "\n".join(lines), reply_markup=keyboard)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
async def handle_plan_callback(client, chat_id, data: str) -> None:
|
|
726
|
+
"""Handle Done/Cancel callback from plan approval buttons."""
|
|
727
|
+
parts = data.split(":", 2)
|
|
728
|
+
if len(parts) != 3:
|
|
729
|
+
return
|
|
730
|
+
_, action, context_id = parts
|
|
731
|
+
pending = _bot_pending_plans.pop(context_id, None)
|
|
732
|
+
|
|
733
|
+
if action == "cancel" or not pending:
|
|
734
|
+
await send_message(client, chat_id, "❌ 작업이 취소되었습니다.")
|
|
735
|
+
return
|
|
736
|
+
|
|
737
|
+
await send_message(client, chat_id, "⚙️ 실행 중입니다. 잠시 기다려주세요...")
|
|
738
|
+
await send_chat_action(client, chat_id, "typing")
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
async with _server_client() as sc:
|
|
742
|
+
res = await sc.post(AGENT_RESUME_URL, json={
|
|
743
|
+
"context_id": context_id,
|
|
744
|
+
"approved": True,
|
|
745
|
+
"executing_model": pending.get("executing_model"),
|
|
746
|
+
"reviewing_model": pending.get("reviewing_model"),
|
|
747
|
+
}, timeout=300.0)
|
|
748
|
+
data_r = res.json() if res.status_code == 200 else {}
|
|
749
|
+
ans = data_r.get("response", f"❌ 서버 에러 ({res.status_code})")
|
|
750
|
+
await send_message(client, chat_id, str(ans))
|
|
751
|
+
if isinstance(data_r, dict):
|
|
752
|
+
await send_generated_files(client, chat_id, collect_generated_files(data_r))
|
|
753
|
+
await send_preview_links(client, chat_id, collect_preview_urls(data_r))
|
|
754
|
+
except Exception as e:
|
|
755
|
+
await send_message(client, chat_id, f"❌ 실행 중 오류: {e}")
|
|
756
|
+
|
|
757
|
+
|
|
628
758
|
# ── AI request task ───────────────────────────────────────────────────────────
|
|
629
759
|
|
|
630
760
|
async def process_ai_request(client, chat_id, user_text, image_data=None):
|
|
631
761
|
try:
|
|
632
762
|
await send_chat_action(client, chat_id, "upload_photo" if image_data else "typing")
|
|
763
|
+
logger.info("ask_ai 호출 시작: chat_id=%s text=%r", chat_id, user_text[:30])
|
|
633
764
|
data = await ask_ai(client, user_text, image_data, agent_mode=not image_data)
|
|
765
|
+
logger.info("ask_ai 완료: chat_id=%s result_keys=%s", chat_id, list(data.keys()) if isinstance(data, dict) else type(data))
|
|
766
|
+
|
|
767
|
+
# Human-in-the-loop: show plan and wait for approval
|
|
768
|
+
if isinstance(data, dict) and data.get("status") == "waiting_approval":
|
|
769
|
+
await send_plan_for_approval(client, chat_id, data)
|
|
770
|
+
return
|
|
771
|
+
|
|
634
772
|
ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
|
|
635
773
|
if not ans or not str(ans).strip():
|
|
636
774
|
ans = "⚠️ AI가 답변을 생성하지 못했습니다."
|
|
@@ -639,7 +777,7 @@ async def process_ai_request(client, chat_id, user_text, image_data=None):
|
|
|
639
777
|
await send_generated_files(client, chat_id, collect_generated_files(data))
|
|
640
778
|
await send_preview_links(client, chat_id, collect_preview_urls(data))
|
|
641
779
|
except Exception as e:
|
|
642
|
-
logger.error("process_ai_request 실패 (chat_id=%s): %s", chat_id, e)
|
|
780
|
+
logger.error("process_ai_request 실패 (chat_id=%s): %s", chat_id, e, exc_info=True)
|
|
643
781
|
try:
|
|
644
782
|
await send_message(client, chat_id, "⚠️ 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.")
|
|
645
783
|
except Exception:
|
|
@@ -662,6 +800,9 @@ HELP_TEXT = """\
|
|
|
662
800
|
/mcp — MCP 도구 목록
|
|
663
801
|
/help — 이 도움말
|
|
664
802
|
|
|
803
|
+
/agent <작업> — 멀티 LLM 에이전트 (계획 확인 후 실행)
|
|
804
|
+
/agent <작업> --exec <모델> --review <모델> — 실행/검토 LLM 지정
|
|
805
|
+
|
|
665
806
|
일반 텍스트 → AI에게 질문
|
|
666
807
|
사진 전송 → AI 이미지 분석
|
|
667
808
|
문서 전송(PDF, DOCX, XLSX, PPTX, TXT, CSV) → Knowledge Graph 수집
|
|
@@ -697,6 +838,30 @@ async def handle_command(client, chat_id, command: str, args: str):
|
|
|
697
838
|
await send_mcp_tools(client, chat_id)
|
|
698
839
|
elif cmd in {"help", "h"}:
|
|
699
840
|
await send_message(client, chat_id, HELP_TEXT)
|
|
841
|
+
elif cmd == "agent":
|
|
842
|
+
if not args:
|
|
843
|
+
await send_message(client, chat_id, "사용법: /agent <작업 내용>\n예: /agent 쇼핑몰 메인 페이지 HTML 만들어줘\n\n특정 LLM 지정:\n/agent <작업> --exec openai/gpt-4o --review deepseek/deepseek-chat")
|
|
844
|
+
return
|
|
845
|
+
# Parse optional --exec / --review flags
|
|
846
|
+
exec_model = reviewing_model = None
|
|
847
|
+
task_text = args
|
|
848
|
+
import re as _re
|
|
849
|
+
em = _re.search(r'--exec\s+(\S+)', args)
|
|
850
|
+
rm = _re.search(r'--review\s+(\S+)', args)
|
|
851
|
+
if em:
|
|
852
|
+
exec_model = em.group(1)
|
|
853
|
+
task_text = task_text.replace(em.group(0), "").strip()
|
|
854
|
+
if rm:
|
|
855
|
+
reviewing_model = rm.group(1)
|
|
856
|
+
task_text = task_text.replace(rm.group(0), "").strip()
|
|
857
|
+
await send_chat_action(client, chat_id, "typing")
|
|
858
|
+
data = await ask_ai(client, task_text, agent_mode=True,
|
|
859
|
+
executing_model=exec_model, reviewing_model=reviewing_model)
|
|
860
|
+
if isinstance(data, dict) and data.get("status") == "waiting_approval":
|
|
861
|
+
await send_plan_for_approval(client, chat_id, data)
|
|
862
|
+
else:
|
|
863
|
+
ans = data.get("response", str(data)) if isinstance(data, dict) else str(data)
|
|
864
|
+
await send_message(client, chat_id, ans)
|
|
700
865
|
else:
|
|
701
866
|
await send_message(client, chat_id, f"알 수 없는 명령어: /{cmd}\n/help 로 명령어 목록을 확인하세요.")
|
|
702
867
|
|
|
@@ -730,6 +895,9 @@ async def handle_callback_query(client, callback_query):
|
|
|
730
895
|
elif data.startswith("model:unload:"):
|
|
731
896
|
model_id = data[len("model:unload:"):]
|
|
732
897
|
await do_unload_model(client, chat_id, model_id)
|
|
898
|
+
elif data.startswith("plan:"):
|
|
899
|
+
task = asyncio.create_task(handle_plan_callback(client, chat_id, data))
|
|
900
|
+
task.add_done_callback(_log_task_exception)
|
|
733
901
|
|
|
734
902
|
# ── Main loop ─────────────────────────────────────────────────────────────────
|
|
735
903
|
|