ltcai 8.9.0 → 9.0.0

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.
Files changed (89) hide show
  1. package/README.md +28 -39
  2. package/auto_setup.py +11 -62
  3. package/docs/CHANGELOG.md +76 -237
  4. package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
  5. package/docs/DEVELOPMENT.md +8 -8
  6. package/docs/LEGACY_COMPATIBILITY.md +1 -1
  7. package/docs/ONBOARDING.md +2 -2
  8. package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
  9. package/docs/TRUST_MODEL.md +1 -1
  10. package/docs/WHY_LATTICE.md +2 -2
  11. package/docs/kg-schema.md +1 -1
  12. package/lattice_brain/__init__.py +1 -1
  13. package/lattice_brain/archive.py +4 -9
  14. package/lattice_brain/embeddings.py +38 -2
  15. package/lattice_brain/graph/_kg_common.py +27 -462
  16. package/lattice_brain/graph/_kg_constants.py +243 -0
  17. package/lattice_brain/graph/_kg_fsutil.py +297 -0
  18. package/lattice_brain/graph/discovery.py +0 -948
  19. package/lattice_brain/graph/discovery_index.py +972 -0
  20. package/lattice_brain/graph/retrieval.py +0 -570
  21. package/lattice_brain/graph/retrieval_docgen.py +210 -0
  22. package/lattice_brain/graph/retrieval_vector.py +460 -0
  23. package/lattice_brain/graph/store.py +6 -0
  24. package/lattice_brain/ingestion.py +68 -0
  25. package/lattice_brain/portability.py +1 -9
  26. package/lattice_brain/quality.py +98 -4
  27. package/lattice_brain/runtime/agent_runtime.py +166 -0
  28. package/lattice_brain/runtime/multi_agent.py +1 -1
  29. package/lattice_brain/utils.py +28 -0
  30. package/latticeai/__init__.py +1 -1
  31. package/latticeai/api/chat.py +340 -407
  32. package/latticeai/api/chat_helpers.py +227 -0
  33. package/latticeai/api/computer_use.py +149 -31
  34. package/latticeai/api/marketplace.py +11 -0
  35. package/latticeai/api/permissions.py +3 -3
  36. package/latticeai/api/tools.py +1 -0
  37. package/latticeai/app_factory.py +82 -360
  38. package/latticeai/core/io_utils.py +37 -0
  39. package/latticeai/core/legacy_compatibility.py +1 -1
  40. package/latticeai/core/local_embeddings.py +2 -4
  41. package/latticeai/core/marketplace.py +33 -2
  42. package/latticeai/core/mcp_catalog.py +450 -0
  43. package/latticeai/core/mcp_registry.py +2 -441
  44. package/latticeai/core/users.py +4 -9
  45. package/latticeai/core/workspace_os.py +1 -1
  46. package/latticeai/core/workspace_os_utils.py +3 -17
  47. package/latticeai/integrations/telegram_bot.py +7 -2
  48. package/latticeai/models/model_providers.py +111 -0
  49. package/latticeai/models/router.py +58 -136
  50. package/latticeai/runtime/audit_runtime.py +27 -16
  51. package/latticeai/runtime/automation_runtime.py +9 -0
  52. package/latticeai/runtime/history_runtime.py +163 -0
  53. package/latticeai/runtime/namespace_runtime.py +173 -0
  54. package/latticeai/runtime/network_config_runtime.py +56 -0
  55. package/latticeai/runtime/sso_config_runtime.py +128 -0
  56. package/latticeai/runtime/user_key_runtime.py +106 -0
  57. package/latticeai/services/architecture_readiness.py +2 -2
  58. package/latticeai/services/memory_service.py +213 -0
  59. package/latticeai/services/platform_runtime.py +9 -1
  60. package/latticeai/services/product_readiness.py +11 -11
  61. package/latticeai/services/review_queue.py +64 -11
  62. package/latticeai/services/run_executor.py +21 -0
  63. package/latticeai/services/setup_detection.py +80 -0
  64. package/latticeai/services/tool_dispatch.py +1 -1
  65. package/package.json +1 -1
  66. package/setup_wizard.py +11 -55
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +11 -11
  71. package/static/app/assets/{Act-fZokUnC0.js → Act-21lIXx2E.js} +1 -1
  72. package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
  73. package/static/app/assets/{Capture-D5KV3Cu7.js → Capture-BA7Z2Q1u.js} +1 -1
  74. package/static/app/assets/{Library-C9kyFkSt.js → Library-bFMtyni3.js} +1 -1
  75. package/static/app/assets/System-K6krGCqn.js +1 -0
  76. package/static/app/assets/index-C4R3ws30.js +17 -0
  77. package/static/app/assets/index-ChSeOB02.css +2 -0
  78. package/static/app/assets/primitives-sQU3it5I.js +1 -0
  79. package/static/app/assets/{textarea-CD8UNKIy.js → textarea-DK3Fd_lR.js} +1 -1
  80. package/static/app/index.html +2 -2
  81. package/static/css/tokens.css +4 -2
  82. package/static/sw.js +1 -1
  83. package/tools/local_files.py +6 -0
  84. package/latticeai/runtime/sso_runtime.py +0 -52
  85. package/static/app/assets/Brain-DtyuWubr.js +0 -321
  86. package/static/app/assets/System-VbChmX7r.js +0 -1
  87. package/static/app/assets/index-DCh5AoXt.css +0 -2
  88. package/static/app/assets/index-DPdcPoF0.js +0 -17
  89. package/static/app/assets/primitives-DFeanEV6.js +0 -1
@@ -14,7 +14,7 @@ import subprocess
14
14
  import tempfile
15
15
  import threading
16
16
  from pathlib import Path
17
- from typing import AsyncIterator, Dict, List, Optional
17
+ from typing import Any, AsyncIterator, Dict, Optional
18
18
 
19
19
  from fastapi import APIRouter, HTTPException, Request
20
20
  from fastapi.responses import JSONResponse, StreamingResponse
@@ -28,6 +28,25 @@ from lattice_brain.runtime.hooks import dispatch_tool
28
28
  from latticeai.services.app_context import AppContext
29
29
  from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
30
30
  from tools import AGENT_ROOT, ToolError, ensure_agent_root, execute_tool, knowledge_save, network_status
31
+ # Pure chat helpers (language/intent detection, file-action parsing, recent-context
32
+ # assembly) live in chat_helpers; re-imported here so ``from latticeai.api.chat
33
+ # import <helper>`` keeps resolving. See __all__ below.
34
+ from latticeai.api.chat_helpers import (
35
+ _LANG_HINT,
36
+ build_recent_chat_context,
37
+ detect_language,
38
+ file_action_target,
39
+ format_network_status,
40
+ inline_file_action_content,
41
+ is_clear_command,
42
+ is_current_url_request,
43
+ is_file_action_request,
44
+ is_network_status_request,
45
+ pair_user_history,
46
+ single_text_stream,
47
+ strip_generated_file_content,
48
+ workspace_scope_from_request,
49
+ )
31
50
 
32
51
  class ChatRequest(BaseModel):
33
52
  message: str
@@ -72,195 +91,26 @@ class AgentEvalRequest(BaseModel):
72
91
  skill: str
73
92
  case_id: Optional[str] = None
74
93
 
75
- def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
76
- """Restrict history to one user's exchange.
77
-
78
- Keeps the user's own messages plus assistant replies that directly follow
79
- them. A bare role=="assistant" pass would leak every other user's replies
80
- into this user's prompt context.
81
- """
82
- paired: List[Dict] = []
83
- include_next_assistant = False
84
- for item in history:
85
- if item.get("role") == "assistant":
86
- if include_next_assistant:
87
- paired.append(item)
88
- include_next_assistant = False
89
- elif item.get("user_email") == user_email:
90
- paired.append(item)
91
- include_next_assistant = True
92
- else:
93
- include_next_assistant = False
94
- return paired
95
-
96
-
97
- def detect_language(text: str) -> str:
98
- """Detect language: 'ko' (Korean) or 'en' (English)."""
99
- total = max(len(text), 1)
100
- ko = sum(1 for c in text if '가' <= c <= '힣')
101
- if ko / total > 0.05:
102
- return "ko"
103
- return "en"
104
-
105
- _LANG_HINT = {
106
- "ko": "Respond in Korean (한국어로 답변하세요).",
107
- "en": "Respond in English.",
108
- }
109
-
110
- def is_network_status_request(text: str) -> bool:
111
- """사용자가 현재 IP/네트워크 정보를 물었는지 감지합니다."""
112
- t = (text or "").lower()
113
- has_ip = bool(re.search(r"((?<![a-z0-9])ip(?![a-z0-9])|아이피|ip\s*주소|아이피\s*주소|ipconfig|ifconfig|네트워크)", t))
114
- asks_current = any(word in t for word in ["내", "현재", "지금", "local", "로컬", "주소", "address", "뭐", "알려", "확인", "상태"])
115
- return has_ip and asks_current
116
-
117
- def is_current_url_request(text: str) -> bool:
118
- t = (text or "").lower()
119
- has_url = any(word in t for word in ["url", "주소", "링크", "address"])
120
- asks_current = any(word in t for word in ["현재", "지금", "여기", "접속", "페이지", "브라우저", "알려", "뭐"])
121
- return has_url and asks_current
122
-
123
- def is_clear_command(text: str) -> bool:
124
- return (text or "").strip().lower() in {"/clear", "/clear_all"}
125
-
126
- # Path segments intentionally exclude spaces: allowing spaces let the target
127
- # match swallow preceding words (e.g. "create a text file report.txt" resolved
128
- # to the whole phrase as the path). Chat file targets are single tokens.
129
- _FILE_TARGET_RE = re.compile(
130
- r"(?<![\w.-])(?:[~./\\]?[\w.@()+-]+[\\/])*[\w.@()+-]+"
131
- r"\.(?:py|js|jsx|ts|tsx|md|markdown|txt|json|yaml|yml|toml|html|css|csv|xml|pdf|docx|xlsx|pptx|sh|sql)",
132
- re.IGNORECASE,
133
- )
134
-
135
94
 
136
- def is_file_action_request(text: str) -> bool:
137
- """Return True for chat requests that should execute file tools, not prose.
138
-
139
- The Brain chat composer posts to ``/chat``. Without this gate, explicit
140
- side-effect requests such as "create hello.md" are handled as plain model
141
- generation, which commonly produces a code block instead of a real file.
142
- Keep the gate narrow so normal Q&A and "how do I create a file?" stay in
143
- ordinary chat.
144
- """
145
- raw = (text or "").strip()
146
- if not raw:
147
- return False
148
- lower = raw.lower()
149
-
150
- if any(phrase in lower for phrase in (
151
- "how to ", "how do i ", "방법", "어떻게", "예시", "sample", "example",
152
- )) and not any(phrase in lower for phrase in (
153
- "actually create", "real file", "실제로", "파일로", "저장해", "만들어",
154
- )):
155
- return False
156
-
157
- has_target = bool(_FILE_TARGET_RE.search(raw))
158
- has_file_word = any(word in lower for word in (
159
- "file", "파일", "문서", "artifact", "아티팩트", "save as", "저장",
160
- ))
161
- has_action = any(word in lower for word in (
162
- "create", "make", "write", "save", "generate", "edit", "update",
163
- "만들", "생성", "작성", "저장", "수정", "써줘", "만들어줘",
164
- ))
165
-
166
- if not has_action:
167
- return False
168
- if has_target and has_action:
169
- return True
170
- return has_file_word and has_action
171
-
172
-
173
- def file_action_target(text: str) -> Optional[str]:
174
- """Extract the first explicit workspace file target from a request."""
175
- match = _FILE_TARGET_RE.search(text or "")
176
- if not match:
177
- return None
178
- return match.group(0).strip().strip("`'\".,:;)]}")
179
-
180
-
181
- def inline_file_action_content(text: str) -> Optional[str]:
182
- """Extract short user-provided content for deterministic file writes."""
183
- raw = (text or "").strip()
184
- # Each pattern requires an explicit binder so ambiguous words like "text"
185
- # ("create a text file report.txt") or a bare "with" ("report.md with a
186
- # summary of X") do not get captured as literal file content.
187
- patterns = [
188
- r"(?:내용|본문)\s*(?:은|는|이에요|입니다)\s*(.+)$",
189
- r"(?:내용|본문|content|body|text)\s*[:=]\s*(.+)$",
190
- r"(?:content|body)\s+(?:is|as)\s+(.+)$",
191
- r"(?:with the content|with content|containing)\s+(.+)$",
192
- ]
193
- for pattern in patterns:
194
- match = re.search(pattern, raw, flags=re.IGNORECASE | re.DOTALL)
195
- if match:
196
- content = match.group(1).strip()
197
- return content.strip("`'\"")
198
- return None
199
-
200
-
201
- def strip_generated_file_content(text: str) -> str:
202
- """Remove common chat wrappers when a model is asked for file content only."""
203
- content = (text or "").strip()
204
- fenced = re.search(r"```(?:[\w.+-]+)?\s*(.*?)\s*```", content, flags=re.DOTALL)
205
- if fenced:
206
- content = fenced.group(1).strip()
207
- return content
208
-
209
-
210
- def format_network_status(info: Dict) -> str:
211
- lines = [
212
- f"내부 IP: {info.get('local_ip') or '확인 안 됨'}",
213
- f"외부 IP: {info.get('public_ip') or '확인 안 됨'}",
214
- f"호스트명: {info.get('hostname') or '확인 안 됨'}",
215
- ]
216
- local_ips = info.get("local_ips") or {}
217
- if local_ips:
218
- lines.extend(["", "인터페이스:"])
219
- lines.extend(f"- {name}: {ip}" for name, ip in local_ips.items())
220
- note = info.get("note")
221
- if note:
222
- lines.extend(["", note])
223
- return "\n".join(lines)
224
-
225
- def workspace_scope_from_request(request: Request) -> Optional[str]:
226
- header = request.headers.get("X-Workspace-Id")
227
- if header and header.strip():
228
- return header.strip()
229
- query = request.query_params.get("workspace_id")
230
- return query.strip() if query and query.strip() else None
231
-
232
- async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
233
- yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
234
- yield "data: [DONE]\n\n"
235
-
236
-
237
- def build_recent_chat_context(
238
- *,
239
- get_history,
240
- limit: int = 10,
241
- include_image_missing_replies: bool = True,
242
- user_email: Optional[str] = None,
243
- conversation_id: Optional[str] = None,
244
- ) -> str:
245
- history = get_history()
246
- if conversation_id:
247
- history = [item for item in history if item.get("conversation_id") == conversation_id]
248
- if user_email:
249
- history = pair_user_history(history, user_email)
250
- history = history[-limit:]
251
- lines = []
252
- for item in history:
253
- role = item.get("role", "user")
254
- content = item.get("content", "")
255
- if not include_image_missing_replies and role == "assistant":
256
- if "이미지" in content and any(word in content for word in ["업로드", "제공", "올려"]):
257
- continue
258
- source = item.get("source")
259
- label = role
260
- if source:
261
- label = f"{role} ({source})"
262
- lines.append(f"{label}: {content}")
263
- return "\n".join(lines)
95
+ # The chat helpers imported at the top of the module are re-exported so
96
+ # ``from latticeai.api.chat import <helper>`` (tests, app_factory) keeps resolving
97
+ # after the split; __all__ marks them as intentional re-exports.
98
+ __all__ = [
99
+ "create_chat_router",
100
+ "build_recent_chat_context",
101
+ "pair_user_history",
102
+ "detect_language",
103
+ "file_action_target",
104
+ "inline_file_action_content",
105
+ "is_file_action_request",
106
+ "is_network_status_request",
107
+ "is_current_url_request",
108
+ "is_clear_command",
109
+ "format_network_status",
110
+ "strip_generated_file_content",
111
+ "workspace_scope_from_request",
112
+ "single_text_stream",
113
+ ]
264
114
 
265
115
 
266
116
  def create_chat_router(context: AppContext) -> APIRouter:
@@ -298,6 +148,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
298
148
  _doc_gen_sessions: dict = {}
299
149
  _pending_agents: dict[str, tuple] = {}
300
150
  _pending_agents_lock = threading.Lock()
151
+ _background_tasks: set[asyncio.Task] = set()
301
152
 
302
153
  on_chat_message = context.on_chat_message
303
154
 
@@ -315,6 +166,232 @@ def create_chat_router(context: AppContext) -> APIRouter:
315
166
  except Exception as exc:
316
167
  logging.warning("chat message bridge failed: %s", exc)
317
168
 
169
+ def schedule_background_task(coro) -> None:
170
+ task = asyncio.create_task(coro)
171
+ _background_tasks.add(task)
172
+
173
+ def _finish(done: asyncio.Task) -> None:
174
+ _background_tasks.discard(done)
175
+ try:
176
+ done.result()
177
+ except asyncio.CancelledError:
178
+ pass
179
+ except Exception as exc:
180
+ logging.warning("background chat task failed: %s", exc)
181
+
182
+ task.add_done_callback(_finish)
183
+
184
+ async def save_history_entry(role: str, content: str, history_meta: Dict, history_user: Dict) -> None:
185
+ await asyncio.to_thread(save_to_history, role, content, **history_meta, **history_user)
186
+
187
+ async def persist_chat_exchange(
188
+ req: ChatRequest,
189
+ answer: str,
190
+ *,
191
+ history_meta: Dict,
192
+ history_user: Dict,
193
+ user_message: Optional[str] = None,
194
+ ) -> None:
195
+ message = user_message if user_message is not None else req.message
196
+ await save_history_entry("user", message, history_meta, history_user)
197
+ await save_history_entry("assistant", answer, history_meta, history_user)
198
+ notify_chat_message("user", req.message, req.source)
199
+ notify_chat_message("assistant", answer, req.source)
200
+
201
+ def no_model_response() -> JSONResponse:
202
+ detail = "No model loaded. Call /models/load first."
203
+ if CONFIG.is_public:
204
+ detail = f"No public model loaded. Set OPENAI_API_KEY and LATTICEAI_PUBLIC_MODEL={PUBLIC_MODEL}, or call /models/load with an OpenAI-compatible model."
205
+ return JSONResponse(
206
+ status_code=400,
207
+ content={
208
+ "error": "no_model_loaded",
209
+ "detail": detail,
210
+ "message": detail,
211
+ "action": "load_model",
212
+ },
213
+ )
214
+
215
+ def single_answer_response(req: ChatRequest, answer: str, *, model: str) -> JSONResponse | StreamingResponse:
216
+ if req.stream:
217
+ return StreamingResponse(
218
+ single_text_stream(answer),
219
+ media_type="text/event-stream",
220
+ headers={"X-Model": model},
221
+ )
222
+ return JSONResponse(content={"response": answer})
223
+
224
+ def agent_payload_stream(answer: str, payload: Dict[str, Any]) -> AsyncIterator[str]:
225
+ async def _stream() -> AsyncIterator[str]:
226
+ yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
227
+ yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
228
+ yield "data: [DONE]\n\n"
229
+
230
+ return _stream()
231
+
232
+ async def handle_network_status_intent(
233
+ req: ChatRequest,
234
+ *,
235
+ history_meta: Dict,
236
+ history_user: Dict,
237
+ ) -> JSONResponse | StreamingResponse:
238
+ history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
239
+ try:
240
+ answer = format_network_status(network_status())
241
+ except ToolError as exc:
242
+ answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
243
+ await persist_chat_exchange(req, answer, history_meta=history_meta, history_user=history_user, user_message=history_message)
244
+ return single_answer_response(req, answer, model="network_status")
245
+
246
+ async def handle_clear_intent(
247
+ req: ChatRequest,
248
+ *,
249
+ effective_email: Optional[str],
250
+ ) -> JSONResponse | StreamingResponse:
251
+ command = req.message.strip().lower()
252
+ clear_scope = "all" if command == "/clear_all" else "conversation"
253
+ if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
254
+ try:
255
+ KNOWLEDGE_GRAPH.ingest_event(
256
+ "ClearEvent",
257
+ f"{command} requested",
258
+ user_email=effective_email,
259
+ user_nickname=req.user_nickname,
260
+ source=req.source or "web",
261
+ conversation_id=req.conversation_id,
262
+ metadata={"command": command, "scope": clear_scope},
263
+ )
264
+ except Exception as e:
265
+ logging.warning("knowledge graph clear event ingest failed: %s", e)
266
+ if command == "/clear_all":
267
+ result = clear_history(0, **history_scope_for_user(effective_email))
268
+ answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
269
+ elif req.conversation_id:
270
+ result = clear_conversation(req.conversation_id, **history_scope_for_user(effective_email))
271
+ answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
272
+ else:
273
+ result = clear_history(0, **history_scope_for_user(effective_email))
274
+ answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
275
+ append_audit_event(
276
+ "clear_command",
277
+ user_email=effective_email,
278
+ user_nickname=req.user_nickname,
279
+ source=req.source or "web",
280
+ conversation_id=req.conversation_id,
281
+ command=command,
282
+ scope=clear_scope,
283
+ removed=result.get("removed", 0),
284
+ kept=result.get("kept", 0),
285
+ )
286
+ notify_chat_message("user", req.message, req.source)
287
+ notify_chat_message("assistant", answer, req.source)
288
+ return single_answer_response(req, answer, model="history")
289
+
290
+ async def handle_current_url_intent(
291
+ req: ChatRequest,
292
+ *,
293
+ history_meta: Dict,
294
+ history_user: Dict,
295
+ ) -> JSONResponse | StreamingResponse:
296
+ answer = f"현재 페이지 URL: {req.client_url}"
297
+ await persist_chat_exchange(req, answer, history_meta=history_meta, history_user=history_user)
298
+ return single_answer_response(req, answer, model="client_url")
299
+
300
+ async def handle_direct_file_action(req: ChatRequest) -> Optional[JSONResponse | StreamingResponse]:
301
+ target_path = file_action_target(req.message)
302
+ if not target_path:
303
+ return None
304
+ content = inline_file_action_content(req.message)
305
+ if content is None and not router.current_model_id:
306
+ return no_model_response()
307
+ if content is None and router.current_model_id:
308
+ if req.model and req.model != router.current_model_id:
309
+ if req.model not in router.loaded_model_ids:
310
+ raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
311
+ router.switch_model(req.model)
312
+ generation_context = (
313
+ "Create the exact content for the requested file. "
314
+ "Return only the file bytes as plain text. "
315
+ "Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
316
+ f"Target path: {target_path}\n"
317
+ f"User request: {req.message}"
318
+ )
319
+ raw_content = await router.generate_as(
320
+ router.current_model_id,
321
+ message="Return only the requested file content.",
322
+ context=generation_context,
323
+ max_tokens=req.max_tokens,
324
+ temperature=req.temperature,
325
+ )
326
+ content = strip_generated_file_content(str(raw_content))
327
+ if content is None:
328
+ raise HTTPException(status_code=400, detail="File content could not be generated.")
329
+ try:
330
+ result = execute_tool("write_file", {"path": target_path, "content": content})
331
+ except ToolError as exc:
332
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
333
+ answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
334
+ created_files = [{
335
+ "path": result.get("path") or target_path,
336
+ "filename": Path(result.get("path") or target_path).name,
337
+ "bytes": result.get("bytes", 0),
338
+ "action": "write_file",
339
+ }]
340
+ notify_chat_message("user", req.message, req.source)
341
+ notify_chat_message("assistant", answer, req.source)
342
+ payload = {
343
+ "status": "ok",
344
+ "response": answer,
345
+ "workspace": str(AGENT_ROOT),
346
+ "steps": [{
347
+ "state": AgentState.EXECUTING.value,
348
+ "action": "write_file",
349
+ "args": {"path": target_path},
350
+ "result": result,
351
+ }],
352
+ "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
353
+ "final_state": AgentState.DONE.value,
354
+ "created_files": created_files,
355
+ "routed_to_agent": True,
356
+ "action_route": "direct_write_file",
357
+ }
358
+ if req.stream:
359
+ return StreamingResponse(
360
+ agent_payload_stream(answer, payload),
361
+ media_type="text/event-stream",
362
+ headers={"X-Model": router.current_model_id or "tool", "X-Routed-To": "agent"},
363
+ )
364
+ return JSONResponse(content=payload)
365
+
366
+ async def route_file_action_to_agent(
367
+ req: ChatRequest,
368
+ request: Request,
369
+ *,
370
+ effective_email: Optional[str],
371
+ ) -> JSONResponse | StreamingResponse:
372
+ agent_req = AgentRequest(
373
+ message=req.message,
374
+ conversation_id=req.conversation_id,
375
+ source=req.source or "web",
376
+ max_steps=25,
377
+ temperature=min(req.temperature, 0.2),
378
+ user_email=effective_email,
379
+ user_nickname=req.user_nickname,
380
+ workspace_id=workspace_scope_from_request(request),
381
+ )
382
+ result = await agent(agent_req, request)
383
+ answer = str(result.get("response") or "파일 작업을 처리했습니다.")
384
+ notify_chat_message("user", req.message, req.source)
385
+ notify_chat_message("assistant", answer, req.source)
386
+ result["routed_to_agent"] = True
387
+ if req.stream:
388
+ return StreamingResponse(
389
+ agent_payload_stream(answer, result),
390
+ media_type="text/event-stream",
391
+ headers={"X-Model": router.current_model_id, "X-Routed-To": "agent"},
392
+ )
393
+ return JSONResponse(content=result)
394
+
318
395
  def history_scope_for_user(user_email: Optional[str]) -> Dict:
319
396
  require_auth = bool(getattr(CONFIG, "require_auth", False))
320
397
  scoped_user = user_email if require_auth else None
@@ -415,9 +492,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
415
492
  current_user = require_user(request)
416
493
  enforce_rate_limit(current_user, "chat")
417
494
  img_len = len(req.image_data) if req.image_data else 0
418
- print(
419
- f"🧪 /chat request: stream={req.stream} image_data_len={img_len} "
420
- f"message_len={len(req.message or '')}"
495
+ logging.debug(
496
+ "/chat request: stream=%s image_data_len=%s message_len=%s",
497
+ req.stream,
498
+ img_len,
499
+ len(req.message or ""),
421
500
  )
422
501
  effective_email = req.user_email or current_user or None
423
502
  history_user = get_history_user(effective_email, req.user_nickname)
@@ -428,162 +507,21 @@ def create_chat_router(context: AppContext) -> APIRouter:
428
507
  }
429
508
 
430
509
  if is_network_status_request(req.message):
431
- history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
432
- save_to_history("user", history_message, **history_meta, **history_user)
433
- try:
434
- answer = format_network_status(network_status())
435
- except ToolError as exc:
436
- answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
437
- save_to_history("assistant", answer, **history_meta, **history_user)
438
- notify_chat_message("user", req.message, req.source)
439
- notify_chat_message("assistant", answer, req.source)
440
- if req.stream:
441
- return StreamingResponse(
442
- single_text_stream(answer),
443
- media_type="text/event-stream",
444
- headers={"X-Model": "network_status"},
445
- )
446
- return JSONResponse(content={"response": answer})
510
+ return await handle_network_status_intent(req, history_meta=history_meta, history_user=history_user)
447
511
 
448
512
  if is_clear_command(req.message):
449
- command = req.message.strip().lower()
450
- clear_scope = "all" if command == "/clear_all" else "conversation"
451
- if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
452
- try:
453
- KNOWLEDGE_GRAPH.ingest_event(
454
- "ClearEvent",
455
- f"{command} requested",
456
- user_email=effective_email,
457
- user_nickname=req.user_nickname,
458
- source=req.source or "web",
459
- conversation_id=req.conversation_id,
460
- metadata={"command": command, "scope": clear_scope},
461
- )
462
- except Exception as e:
463
- logging.warning("knowledge graph clear event ingest failed: %s", e)
464
- if command == "/clear_all":
465
- result = clear_history(0, **history_scope_for_user(effective_email))
466
- answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
467
- else:
468
- if req.conversation_id:
469
- result = clear_conversation(req.conversation_id, **history_scope_for_user(effective_email))
470
- answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
471
- else:
472
- result = clear_history(0, **history_scope_for_user(effective_email))
473
- answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
474
- append_audit_event(
475
- "clear_command",
476
- user_email=effective_email,
477
- user_nickname=req.user_nickname,
478
- source=req.source or "web",
479
- conversation_id=req.conversation_id,
480
- command=command,
481
- scope=clear_scope,
482
- removed=result.get("removed", 0),
483
- kept=result.get("kept", 0),
484
- )
485
- if req.stream:
486
- return StreamingResponse(
487
- single_text_stream(answer),
488
- media_type="text/event-stream",
489
- headers={"X-Model": "history"},
490
- )
491
- return JSONResponse(content={"response": answer})
513
+ return await handle_clear_intent(req, effective_email=effective_email)
492
514
 
493
515
  if is_current_url_request(req.message) and req.client_url:
494
- answer = f"현재 페이지 URL: {req.client_url}"
495
- save_to_history("user", req.message, **history_meta, **history_user)
496
- save_to_history("assistant", answer, **history_meta, **history_user)
497
- notify_chat_message("user", req.message, req.source)
498
- notify_chat_message("assistant", answer, req.source)
499
- if req.stream:
500
- return StreamingResponse(
501
- single_text_stream(answer),
502
- media_type="text/event-stream",
503
- headers={"X-Model": "client_url"},
504
- )
505
- return JSONResponse(content={"response": answer})
516
+ return await handle_current_url_intent(req, history_meta=history_meta, history_user=history_user)
506
517
 
507
518
  if is_file_action_request(req.message):
508
- target_path = file_action_target(req.message)
509
- if target_path:
510
- content = inline_file_action_content(req.message)
511
- if content is None and router.current_model_id:
512
- if req.model and req.model != router.current_model_id:
513
- if req.model not in router.loaded_model_ids:
514
- raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
515
- router.switch_model(req.model)
516
- generation_context = (
517
- "Create the exact content for the requested file. "
518
- "Return only the file bytes as plain text. "
519
- "Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
520
- f"Target path: {target_path}\n"
521
- f"User request: {req.message}"
522
- )
523
- raw_content = await router.generate_as(
524
- router.current_model_id,
525
- message="Return only the requested file content.",
526
- context=generation_context,
527
- max_tokens=req.max_tokens,
528
- temperature=req.temperature,
529
- )
530
- content = strip_generated_file_content(str(raw_content))
531
- if content is None:
532
- content = ""
533
- try:
534
- result = execute_tool("write_file", {"path": target_path, "content": content})
535
- except ToolError as exc:
536
- raise HTTPException(status_code=400, detail=str(exc)) from exc
537
- answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
538
- created_files = [{
539
- "path": result.get("path") or target_path,
540
- "filename": Path(result.get("path") or target_path).name,
541
- "bytes": result.get("bytes", 0),
542
- "action": "write_file",
543
- }]
544
- notify_chat_message("user", req.message, req.source)
545
- notify_chat_message("assistant", answer, req.source)
546
- payload = {
547
- "status": "ok",
548
- "response": answer,
549
- "workspace": str(AGENT_ROOT),
550
- "steps": [{
551
- "state": AgentState.EXECUTING.value,
552
- "action": "write_file",
553
- "args": {"path": target_path},
554
- "result": result,
555
- }],
556
- "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
557
- "final_state": AgentState.DONE.value,
558
- "created_files": created_files,
559
- "routed_to_agent": True,
560
- "action_route": "direct_write_file",
561
- }
562
- if req.stream:
563
- async def _stream_file_result():
564
- yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
565
- yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
566
- yield "data: [DONE]\n\n"
567
- return StreamingResponse(
568
- _stream_file_result(),
569
- media_type="text/event-stream",
570
- headers={"X-Model": router.current_model_id or "tool", "X-Routed-To": "agent"},
571
- )
572
- return JSONResponse(content=payload)
519
+ direct_response = await handle_direct_file_action(req)
520
+ if direct_response is not None:
521
+ return direct_response
573
522
 
574
523
  if not router.current_model_id:
575
- detail = "No model loaded. Call /models/load first."
576
- if CONFIG.is_public:
577
- detail = f"No public model loaded. Set OPENAI_API_KEY and LATTICEAI_PUBLIC_MODEL={PUBLIC_MODEL}, or call /models/load with an OpenAI-compatible model."
578
- return JSONResponse(
579
- status_code=400,
580
- content={
581
- "error": "no_model_loaded",
582
- "detail": detail,
583
- "message": detail,
584
- "action": "load_model",
585
- },
586
- )
524
+ return no_model_response()
587
525
 
588
526
  if req.model and req.model != router.current_model_id:
589
527
  if req.model not in router.loaded_model_ids:
@@ -591,32 +529,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
591
529
  router.switch_model(req.model)
592
530
 
593
531
  if is_file_action_request(req.message):
594
- agent_req = AgentRequest(
595
- message=req.message,
596
- conversation_id=req.conversation_id,
597
- source=req.source or "web",
598
- max_steps=25,
599
- temperature=min(req.temperature, 0.2),
600
- user_email=effective_email,
601
- user_nickname=req.user_nickname,
602
- workspace_id=workspace_scope_from_request(request),
603
- )
604
- result = await agent(agent_req, request)
605
- answer = str(result.get("response") or "파일 작업을 처리했습니다.")
606
- notify_chat_message("user", req.message, req.source)
607
- notify_chat_message("assistant", answer, req.source)
608
- result["routed_to_agent"] = True
609
- if req.stream:
610
- async def _stream_agent_result():
611
- yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
612
- yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
613
- yield "data: [DONE]\n\n"
614
- return StreamingResponse(
615
- _stream_agent_result(),
616
- media_type="text/event-stream",
617
- headers={"X-Model": router.current_model_id, "X-Routed-To": "agent"},
618
- )
619
- return JSONResponse(content=result)
532
+ return await route_file_action_to_agent(req, request, effective_email=effective_email)
620
533
 
621
534
  lang = detect_language(req.message)
622
535
  context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
@@ -651,7 +564,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
651
564
  graph_md = doc_gen_context_result.get("context_markdown", "")
652
565
  if graph_md:
653
566
  context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
654
- print("📝 Document generation context retrieved from knowledge graph.")
567
+ logging.debug("Document generation context retrieved from knowledge graph.")
655
568
  except Exception as e:
656
569
  logging.warning("Knowledge graph reinforcement skipped: %s", e)
657
570
 
@@ -691,7 +604,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
691
604
  trace_seed["context_assembly"] = context_trace
692
605
 
693
606
  history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
694
- save_to_history("user", history_message, **history_meta, **history_user)
607
+ await asyncio.to_thread(save_to_history, "user", history_message, **history_meta, **history_user)
695
608
  notify_chat_message("user", req.message, req.source)
696
609
 
697
610
  if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
@@ -708,19 +621,27 @@ def create_chat_router(context: AppContext) -> APIRouter:
708
621
  if req.stream:
709
622
  async def _stream_doc_gen():
710
623
  collected = []
711
- async for chunk in router.stream_generate_document(
712
- req.message, system_prompt,
713
- max_tokens=req.max_tokens or 8192,
714
- temperature=req.temperature or 0.3,
715
- ):
716
- collected.append(chunk)
717
- yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n"
624
+ stream_error = None
625
+ try:
626
+ async for chunk in router.stream_generate_document(
627
+ req.message, system_prompt,
628
+ max_tokens=req.max_tokens or 8192,
629
+ temperature=req.temperature or 0.3,
630
+ ):
631
+ collected.append(chunk)
632
+ yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n"
633
+ except Exception as exc:
634
+ stream_error = str(exc)
635
+ logging.warning("document stream failed: %s", exc)
636
+ yield f"data: {json.dumps({'error': stream_error}, ensure_ascii=False)}\n\n"
718
637
  full_text = "".join(collected)
719
638
  if footnote:
720
639
  yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
721
640
  full_text += footnote
641
+ if stream_error:
642
+ full_text = f"{full_text}\n\n[stream_error] {stream_error}" if full_text else f"[stream_error] {stream_error}"
722
643
  session.update(graph_md, full_text, req.conversation_id)
723
- save_to_history("assistant", full_text, **history_meta, **history_user)
644
+ await asyncio.to_thread(save_to_history, "assistant", full_text, **history_meta, **history_user)
724
645
  trace_record = CHAT_SERVICE.record_trace(
725
646
  question=req.message,
726
647
  response=full_text,
@@ -745,7 +666,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
745
666
  if footnote:
746
667
  result += footnote
747
668
  session.update(graph_md, result, req.conversation_id)
748
- save_to_history("assistant", str(result), **history_meta, **history_user)
669
+ await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
749
670
  trace_record = CHAT_SERVICE.record_trace(
750
671
  question=req.message,
751
672
  response=str(result),
@@ -788,7 +709,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
788
709
 
789
710
  result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
790
711
 
791
- save_to_history("assistant", str(result), **history_meta, **history_user)
712
+ await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
792
713
  trace_record = CHAT_SERVICE.record_trace(
793
714
  question=req.message,
794
715
  response=str(result),
@@ -883,33 +804,45 @@ def create_chat_router(context: AppContext) -> APIRouter:
883
804
  history_meta: Optional[Dict] = None,
884
805
  ) -> AsyncIterator[str]:
885
806
  full_response = ""
886
- async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
887
- clean_chunk = chunk
888
- if hasattr(chunk, "text"):
889
- clean_chunk = chunk.text
890
- elif isinstance(chunk, str) and "text='" in chunk:
891
- try:
892
- clean_chunk = chunk.split("text='")[1].split("', token=")[0].replace('\\n', '\n').replace('\\\\n', '\n')
893
- except Exception:
894
- pass
895
-
896
- full_response += str(clean_chunk)
897
- yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
807
+ stream_error: Optional[str] = None
808
+ try:
809
+ async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
810
+ clean_chunk = chunk.text if hasattr(chunk, "text") else chunk
811
+ full_response += str(clean_chunk)
812
+ yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
813
+ except Exception as exc:
814
+ stream_error = str(exc)
815
+ logging.warning("chat stream failed: %s", exc)
816
+ yield f"data: {json.dumps({'error': stream_error, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
898
817
  history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
899
- save_to_history("assistant", full_response, **(history_meta or {}), **history_user)
900
- trace_record = CHAT_SERVICE.record_trace(
901
- question=req.message,
902
- response=full_response,
903
- conversation_id=req.conversation_id,
904
- user_email=effective_email or req.user_email,
905
- trace=trace_seed or CHAT_SERVICE.build_graph_trace(
906
- req.message,
907
- KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
908
- context,
909
- ),
910
- )
911
- notify_chat_message("assistant", full_response, req.source)
912
- yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
818
+ persisted_response = full_response
819
+ if stream_error and not persisted_response:
820
+ persisted_response = f"[stream_error] {stream_error}"
821
+ elif stream_error:
822
+ persisted_response = f"{persisted_response}\n\n[stream_error] {stream_error}"
823
+ trace_record = None
824
+ try:
825
+ await asyncio.to_thread(save_to_history, "assistant", persisted_response, **(history_meta or {}), **history_user)
826
+ trace_record = CHAT_SERVICE.record_trace(
827
+ question=req.message,
828
+ response=persisted_response,
829
+ conversation_id=req.conversation_id,
830
+ user_email=effective_email or req.user_email,
831
+ trace=trace_seed or CHAT_SERVICE.build_graph_trace(
832
+ req.message,
833
+ KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
834
+ context,
835
+ ),
836
+ )
837
+ notify_chat_message("assistant", persisted_response, req.source)
838
+ except Exception as exc:
839
+ logging.warning("chat stream persistence failed: %s", exc)
840
+ trailer = {"chunk": "", "model": router.current_model_id}
841
+ if trace_record:
842
+ trailer.update({"trace_id": trace_record["id"], "trace": trace_record})
843
+ if stream_error:
844
+ trailer["error"] = stream_error
845
+ yield f"data: {json.dumps(trailer, ensure_ascii=False)}\n\n"
913
846
  yield "data: [DONE]\n\n"
914
847
 
915
848
  @api_router.post("/agent/eval")
@@ -1018,11 +951,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
1018
951
  ) -> dict:
1019
952
  """HTTP glue: drive the runtime to a terminal state, persist, shape the response."""
1020
953
  await _AGENT_RUNTIME.run_to_completion(ctx, req, lang_hint, current_user, max_steps, max_retry)
1021
- asyncio.create_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
954
+ schedule_background_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
1022
955
 
1023
956
  message = ctx.final_message or "작업을 완료했습니다."
1024
- save_to_history("user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
1025
- save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
957
+ await asyncio.to_thread(save_to_history, "user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
958
+ await asyncio.to_thread(save_to_history, "assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
1026
959
  try:
1027
960
  WORKSPACE_OS.record_agent_run(
1028
961
  agent_id="agent:executor",