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 CHANGED
@@ -14,10 +14,10 @@ Lattice AI는 개인 개발자가 로컬 모델, 클라우드 모델, 에이전
14
14
 
15
15
  ### 현재 배포 버전
16
16
 
17
- - `PyPI`: `ltcai==0.1.16`
18
- - `npm`: `ltcai@0.1.16`
19
- - `VS Code Marketplace`: `parktaesoo.ltcai@0.1.16`
20
- - `Open VSX`: `parktaesoo.ltcai@0.1.16`
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.16 릴리스는 아래 네 채널을 동일 버전으로 맞춥니다.
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.16** — 자세한 변경 이력은 [docs/CHANGELOG.md](docs/CHANGELOG.md) 참고.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Lattice AI local MLX/cloud LLM workspace server",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
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
- return DATA_DIR / "sessions.json"
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: AgentState = AgentState.IDLE
2065
- self.plan: dict = {}
2066
- self.transcript: list = []
2067
- self.retry_count: int = 0
2068
- self.state_history: list = []
2069
- self.corrections: list = []
2070
- self.final_message: str = ""
2071
- self.rollback_log: list = []
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.generate(
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.generate(
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.generate(
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.IDLE:
4698
- ctx.state = AgentState.PLANNING
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> &nbsp;·&nbsp;
3747
+ ⚙️ Executing: <b>${escapeHtml(compactModelName(eM))}</b> &nbsp;·&nbsp;
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
- aiMsgDiv.innerHTML = `<div class="sender-label">Lattice AI <span style="color:var(--accent);font-size:11px">⚙ 에이전트 모드</span></div><div class="bubble">파일을 생성하고 있습니다...</div>`;
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 httpx.AsyncClient() as lc:
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 httpx.AsyncClient() as lc:
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 httpx.AsyncClient() as lc:
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 httpx.AsyncClient() as lc:
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 httpx.AsyncClient() as lc:
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 httpx.AsyncClient() as lc:
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 httpx.AsyncClient() as lc:
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 httpx.AsyncClient() as lc:
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 httpx.AsyncClient() as lc:
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=True):
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
- url = CHAT_URL if image_data or not agent_mode else AGENT_URL
542
- payload = {"message": message, "source": "telegram"}
543
- if image_data:
544
- payload["stream"] = False
545
- payload["image_data"] = image_data
546
- res = await client.post(url, json=payload, timeout=300.0)
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