ltcai 9.0.0 → 9.2.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 (189) hide show
  1. package/README.md +88 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +178 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +4 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +28 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +316 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +50 -13
  71. package/latticeai/core/agent_prompts.py +5 -0
  72. package/latticeai/core/agent_registry.py +1 -5
  73. package/latticeai/core/config.py +23 -3
  74. package/latticeai/core/context_builder.py +21 -3
  75. package/latticeai/core/file_generation.py +451 -0
  76. package/latticeai/core/invitations.py +1 -4
  77. package/latticeai/core/io_utils.py +9 -1
  78. package/latticeai/core/legacy_compatibility.py +24 -3
  79. package/latticeai/core/marketplace.py +1 -1
  80. package/latticeai/core/plugins.py +15 -0
  81. package/latticeai/core/policy.py +1 -1
  82. package/latticeai/core/realtime.py +38 -15
  83. package/latticeai/core/sessions.py +7 -2
  84. package/latticeai/core/timeutil.py +10 -0
  85. package/latticeai/core/tool_registry.py +90 -18
  86. package/latticeai/core/users.py +1 -5
  87. package/latticeai/core/workspace_graph_trace.py +31 -5
  88. package/latticeai/core/workspace_memory.py +3 -1
  89. package/latticeai/core/workspace_os.py +18 -7
  90. package/latticeai/core/workspace_os_utils.py +0 -5
  91. package/latticeai/core/workspace_permissions.py +2 -1
  92. package/latticeai/core/workspace_plugins.py +1 -1
  93. package/latticeai/core/workspace_runs.py +14 -5
  94. package/latticeai/core/workspace_skills.py +1 -1
  95. package/latticeai/core/workspace_snapshots.py +2 -1
  96. package/latticeai/core/workspace_timeline.py +2 -1
  97. package/latticeai/integrations/telegram_bot.py +94 -43
  98. package/latticeai/models/router.py +130 -36
  99. package/latticeai/runtime/access_runtime.py +62 -4
  100. package/latticeai/runtime/automation_runtime.py +13 -7
  101. package/latticeai/runtime/brain_runtime.py +19 -7
  102. package/latticeai/runtime/chat_wiring.py +2 -0
  103. package/latticeai/runtime/config_runtime.py +58 -26
  104. package/latticeai/runtime/context_runtime.py +16 -4
  105. package/latticeai/runtime/hooks_runtime.py +1 -1
  106. package/latticeai/runtime/model_wiring.py +33 -5
  107. package/latticeai/runtime/namespace_runtime.py +62 -72
  108. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  109. package/latticeai/runtime/review_wiring.py +1 -1
  110. package/latticeai/runtime/router_registration.py +34 -1
  111. package/latticeai/runtime/security_runtime.py +110 -13
  112. package/latticeai/runtime/stages.py +27 -0
  113. package/latticeai/server_app.py +5 -1
  114. package/latticeai/services/architecture_readiness.py +74 -5
  115. package/latticeai/services/brain_automation.py +3 -26
  116. package/latticeai/services/chat_service.py +205 -15
  117. package/latticeai/services/local_knowledge.py +423 -0
  118. package/latticeai/services/memory_service.py +55 -21
  119. package/latticeai/services/model_engines.py +48 -33
  120. package/latticeai/services/model_errors.py +17 -0
  121. package/latticeai/services/model_loading.py +28 -25
  122. package/latticeai/services/model_runtime.py +228 -162
  123. package/latticeai/services/p_reinforce.py +22 -3
  124. package/latticeai/services/platform_runtime.py +83 -23
  125. package/latticeai/services/product_readiness.py +19 -17
  126. package/latticeai/services/review_queue.py +12 -0
  127. package/latticeai/services/router_context.py +1 -0
  128. package/latticeai/services/run_executor.py +4 -9
  129. package/latticeai/services/search_service.py +203 -28
  130. package/latticeai/services/tool_dispatch.py +28 -5
  131. package/latticeai/services/triggers.py +53 -4
  132. package/latticeai/services/upload_service.py +13 -2
  133. package/latticeai/setup/__init__.py +25 -0
  134. package/latticeai/setup/auto_setup.py +857 -0
  135. package/latticeai/setup/wizard.py +1264 -0
  136. package/latticeai/tools/__init__.py +278 -0
  137. package/{tools → latticeai/tools}/commands.py +67 -7
  138. package/{tools → latticeai/tools}/computer.py +1 -1
  139. package/{tools → latticeai/tools}/documents.py +1 -1
  140. package/{tools → latticeai/tools}/filesystem.py +3 -3
  141. package/latticeai/tools/knowledge.py +178 -0
  142. package/{tools → latticeai/tools}/local_files.py +1 -1
  143. package/{tools → latticeai/tools}/network.py +0 -1
  144. package/local_knowledge_api.py +4 -342
  145. package/package.json +22 -2
  146. package/scripts/brain_quality_eval.py +3 -1
  147. package/scripts/bump_version.py +3 -0
  148. package/scripts/capture_release_evidence.mjs +180 -0
  149. package/scripts/check_current_release_docs.mjs +142 -0
  150. package/scripts/check_i18n_literals.mjs +107 -31
  151. package/scripts/check_openapi_drift.mjs +56 -0
  152. package/scripts/export_openapi.py +67 -7
  153. package/scripts/i18n_literal_allowlist.json +22 -24
  154. package/scripts/run_integration_tests.mjs +72 -10
  155. package/scripts/validate_release_artifacts.py +49 -7
  156. package/scripts/wheel_smoke.py +5 -0
  157. package/setup_wizard.py +3 -1260
  158. package/src-tauri/Cargo.lock +1 -1
  159. package/src-tauri/Cargo.toml +1 -1
  160. package/src-tauri/tauri.conf.json +1 -1
  161. package/static/app/asset-manifest.json +11 -11
  162. package/static/app/assets/Act-C3dBrWE-.js +1 -0
  163. package/static/app/assets/Brain-DBYgdcjt.js +321 -0
  164. package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
  165. package/static/app/assets/Library-CFfkNn3s.js +1 -0
  166. package/static/app/assets/System-BOurbT-v.js +1 -0
  167. package/static/app/assets/index-A3M9sElj.js +17 -0
  168. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  169. package/static/app/assets/primitives-DcUUmhdC.js +1 -0
  170. package/static/app/assets/textarea-BklR6zN4.js +1 -0
  171. package/static/app/index.html +2 -2
  172. package/static/sw.js +1 -1
  173. package/tools/__init__.py +21 -269
  174. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  175. package/latticeai/runtime/app_context_runtime.py +0 -13
  176. package/latticeai/runtime/tail_wiring.py +0 -21
  177. package/scripts/com.pts.claudecode.discord.plist +0 -31
  178. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  179. package/scripts/start-pts-claudecode-discord.sh +0 -51
  180. package/static/app/assets/Act-21lIXx2E.js +0 -1
  181. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  182. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  183. package/static/app/assets/Library-bFMtyni3.js +0 -1
  184. package/static/app/assets/System-K6krGCqn.js +0 -1
  185. package/static/app/assets/index-C4R3ws30.js +0 -17
  186. package/static/app/assets/index-ChSeOB02.css +0 -2
  187. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  188. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  189. package/tools/knowledge.py +0 -95
@@ -26,6 +26,13 @@ def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
26
26
  for item in history:
27
27
  if item.get("role") == "assistant":
28
28
  if include_next_assistant:
29
+ assistant_user = item.get("user_email")
30
+ if assistant_user and assistant_user != user_email:
31
+ # Concurrent requests can interleave rows in the global
32
+ # history order. A reply explicitly owned by somebody
33
+ # else is never this user's reply; keep waiting for the
34
+ # next correctly-owned (or legacy ownerless) assistant.
35
+ continue
29
36
  paired.append(item)
30
37
  include_next_assistant = False
31
38
  elif item.get("user_email") == user_email:
@@ -121,6 +128,10 @@ def is_file_action_request(text: str) -> bool:
121
128
  has_target = bool(_FILE_TARGET_RE.search(raw))
122
129
  has_file_word = any(word in lower for word in (
123
130
  "file", "파일", "문서", "artifact", "아티팩트", "save as", "저장",
131
+ # Explicit artifact types: users asking for "an html page" expect a
132
+ # real file, not a code block in chat — route them to the file path
133
+ # where the model-agnostic generation pipeline guarantees validity.
134
+ "html", "웹페이지", "웹 페이지", "홈페이지", "webpage", "web page",
124
135
  ))
125
136
  has_action = any(word in lower for word in (
126
137
  "create", "make", "write", "save", "generate", "edit", "update",
@@ -205,8 +216,24 @@ def build_recent_chat_context(
205
216
  include_image_missing_replies: bool = True,
206
217
  user_email: Optional[str] = None,
207
218
  conversation_id: Optional[str] = None,
219
+ workspace_id: Optional[str] = None,
208
220
  ) -> str:
209
- history = get_history()
221
+ if user_email:
222
+ # Apply identity/workspace isolation in the durable query rather than
223
+ # relying only on positional post-filtering. In particular, never
224
+ # admit legacy-global rows into an authenticated model prompt.
225
+ history = get_history(
226
+ user_email=user_email,
227
+ allowed_workspaces={workspace_id} if workspace_id is not None else None,
228
+ include_legacy_global=False,
229
+ )
230
+ else:
231
+ history = get_history()
232
+ if workspace_id is not None:
233
+ history = [
234
+ item for item in history
235
+ if str(item.get("workspace_id") or "personal") == str(workspace_id)
236
+ ]
210
237
  if conversation_id:
211
238
  history = [item for item in history if item.get("conversation_id") == conversation_id]
212
239
  if user_email:
@@ -0,0 +1,99 @@
1
+ """Conversation-history HTTP routes.
2
+
3
+ Persistence and search grouping live in :mod:`latticeai.services.chat_service`;
4
+ this module only authenticates requests and shapes HTTP responses.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Any, Callable, Dict
11
+
12
+ from fastapi import APIRouter, HTTPException, Request
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class HistoryRouteDependencies:
17
+ chat_service: Any
18
+ require_user: Callable[[Request], str]
19
+ scope_for_user: Callable[[str], Dict[str, Any]]
20
+ group_conversations: Callable[[list], Any]
21
+ get_conversation_messages: Callable[..., list]
22
+ conversation_title: Callable[[Dict[str, Any]], str]
23
+ clear_conversation: Callable[..., Dict[str, Any]]
24
+ clear_history: Callable[..., Dict[str, Any]]
25
+ append_audit_event: Callable[..., None]
26
+
27
+
28
+ def register_history_routes(router: APIRouter, deps: HistoryRouteDependencies) -> None:
29
+ @router.get("/history")
30
+ async def fetch_history(request: Request):
31
+ """웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
32
+ current_user = deps.require_user(request)
33
+ return deps.chat_service.history(**deps.scope_for_user(current_user))
34
+
35
+ @router.get("/history/conversations")
36
+ async def fetch_history_conversations(request: Request):
37
+ """저장된 히스토리를 대화 단위로 묶어 반환합니다."""
38
+ current_user = deps.require_user(request)
39
+ history = deps.chat_service.history(**deps.scope_for_user(current_user))
40
+ return deps.group_conversations(history)
41
+
42
+ @router.get("/history/conversations/{conversation_id:path}")
43
+ async def fetch_history_conversation(conversation_id: str, request: Request):
44
+ """선택한 대화의 메시지를 반환합니다."""
45
+ current_user = deps.require_user(request)
46
+ messages = deps.get_conversation_messages(
47
+ conversation_id,
48
+ **deps.scope_for_user(current_user),
49
+ )
50
+ if not messages:
51
+ raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
52
+ return {"id": conversation_id, "messages": messages}
53
+
54
+ @router.delete("/history/conversations/{conversation_id:path}")
55
+ async def delete_history_conversation(conversation_id: str, request: Request):
56
+ """선택한 대화방의 메시지만 삭제합니다."""
57
+ email = deps.require_user(request)
58
+ started_at = request.query_params.get("started_at")
59
+ result = deps.clear_conversation(
60
+ conversation_id,
61
+ started_at,
62
+ **deps.scope_for_user(email),
63
+ )
64
+ deps.append_audit_event(
65
+ "conversation_delete",
66
+ user_email=email,
67
+ conversation_id=conversation_id,
68
+ started_at=started_at,
69
+ removed=result.get("removed", 0),
70
+ kept=result.get("kept", 0),
71
+ )
72
+ return result
73
+
74
+ @router.delete("/history")
75
+ async def delete_history(request: Request, keep_last: int = 0):
76
+ email = deps.require_user(request)
77
+ result = deps.clear_history(keep_last, **deps.scope_for_user(email))
78
+ deps.append_audit_event(
79
+ "history_delete",
80
+ user_email=email,
81
+ keep_last=keep_last,
82
+ removed=result.get("removed", 0),
83
+ kept=result.get("kept", 0),
84
+ )
85
+ return result
86
+
87
+ @router.get("/history/search")
88
+ async def search_history(q: str, request: Request):
89
+ """키워드로 채팅 히스토리를 검색합니다."""
90
+ current_user = deps.require_user(request)
91
+ results = deps.chat_service.search_history(
92
+ q,
93
+ scope=deps.scope_for_user(current_user),
94
+ conversation_title=deps.conversation_title,
95
+ )
96
+ return {"results": results, "query": q}
97
+
98
+
99
+ __all__ = ["HistoryRouteDependencies", "register_history_routes"]
@@ -0,0 +1,316 @@
1
+ """Fast-path intent and governed file-action handlers for chat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional
8
+
9
+ from fastapi import HTTPException, Request
10
+ from fastapi.responses import JSONResponse, StreamingResponse
11
+
12
+ from latticeai.api.chat_contracts import AgentRequest
13
+ from latticeai.api.chat_helpers import (
14
+ file_action_target,
15
+ format_network_status,
16
+ inline_file_action_content,
17
+ )
18
+ from latticeai.api.chat_stream import agent_payload_stream, single_answer_response
19
+ from latticeai.core.agent import AgentState
20
+ from latticeai.core.file_generation import generate_file_content, infer_file_target
21
+
22
+
23
+ class ChatIntentController:
24
+ def __init__(
25
+ self,
26
+ *,
27
+ model_router: Any,
28
+ config: Any,
29
+ public_model: str,
30
+ chat_service: Any,
31
+ notify: Any,
32
+ clear_history: Any,
33
+ clear_conversation: Any,
34
+ history_scope_for_user: Any,
35
+ append_audit_event: Any,
36
+ enable_graph: bool,
37
+ knowledge_graph: Any,
38
+ enforce_tool_policy: Any,
39
+ network_status: Any,
40
+ tool_error: type[Exception],
41
+ execute_tool: Any,
42
+ agent_controller: Any,
43
+ agent_root: Path,
44
+ ) -> None:
45
+ self.router = model_router
46
+ self.config = config
47
+ self.public_model = public_model
48
+ self.chat_service = chat_service
49
+ self.notify = notify
50
+ self.clear_history = clear_history
51
+ self.clear_conversation = clear_conversation
52
+ self.history_scope_for_user = history_scope_for_user
53
+ self.append_audit_event = append_audit_event
54
+ self.enable_graph = enable_graph
55
+ self.knowledge_graph = knowledge_graph
56
+ self.enforce_tool_policy = enforce_tool_policy
57
+ self.network_status = network_status
58
+ self.tool_error = tool_error
59
+ self.execute_tool = execute_tool
60
+ self.agent_controller = agent_controller
61
+ self.agent_root = Path(agent_root)
62
+
63
+ def no_model_response(self) -> JSONResponse:
64
+ detail = "No model loaded. Call /models/load first."
65
+ if self.config.is_public:
66
+ detail = (
67
+ "No public model loaded. Set OPENAI_API_KEY and "
68
+ f"LATTICEAI_PUBLIC_MODEL={self.public_model}, or call /models/load "
69
+ "with an OpenAI-compatible model."
70
+ )
71
+ return JSONResponse(
72
+ status_code=400,
73
+ content={
74
+ "error": "no_model_loaded",
75
+ "detail": detail,
76
+ "message": detail,
77
+ "action": "load_model",
78
+ },
79
+ )
80
+
81
+ async def network(
82
+ self,
83
+ req: Any,
84
+ *,
85
+ current_user: str,
86
+ history_meta: Dict[str, Any],
87
+ history_user: Dict[str, Any],
88
+ ):
89
+ history_message = (
90
+ f"{req.message}\n[Image attached]" if req.image_data else req.message
91
+ )
92
+ self.enforce_tool_policy(
93
+ "network_status",
94
+ {},
95
+ current_user=current_user,
96
+ source="chat_intent",
97
+ trusted_admin=not bool(getattr(self.config, "require_auth", False)),
98
+ )
99
+ try:
100
+ answer = format_network_status(self.network_status())
101
+ except self.tool_error as exc:
102
+ answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
103
+ await self.chat_service.persist_exchange(
104
+ request_message=req.message,
105
+ stored_user_message=history_message,
106
+ answer=answer,
107
+ source=req.source,
108
+ history_meta=history_meta,
109
+ history_user=history_user,
110
+ notify=self.notify,
111
+ )
112
+ return single_answer_response(req, answer, model="network_status")
113
+
114
+ async def clear(
115
+ self,
116
+ req: Any,
117
+ *,
118
+ effective_email: Optional[str],
119
+ workspace_id: Optional[str],
120
+ ):
121
+ command = req.message.strip().lower()
122
+ clear_scope = "all" if command == "/clear_all" else "conversation"
123
+ if self.enable_graph and self.knowledge_graph:
124
+ try:
125
+ self.knowledge_graph.ingest_event(
126
+ "ClearEvent",
127
+ f"{command} requested",
128
+ user_email=effective_email,
129
+ user_nickname=req.user_nickname,
130
+ source=req.source or "web",
131
+ conversation_id=req.conversation_id,
132
+ workspace_id=workspace_id,
133
+ metadata={"command": command, "scope": clear_scope},
134
+ )
135
+ except Exception as exc:
136
+ # Clear remains available even when optional graph audit ingest
137
+ # is unhealthy; the primary audit event below is authoritative.
138
+ logging.warning("knowledge graph clear event ingest failed: %s", exc)
139
+ scope = self.history_scope_for_user(effective_email)
140
+ if command == "/clear_all":
141
+ result = self.clear_history(0, **scope)
142
+ prefix = "채팅창을 정리했습니다."
143
+ elif req.conversation_id:
144
+ result = self.clear_conversation(req.conversation_id, **scope)
145
+ prefix = "현재 대화방 채팅창을 정리했습니다."
146
+ else:
147
+ result = self.clear_history(0, **scope)
148
+ prefix = "채팅창을 정리했습니다."
149
+ answer = (
150
+ f"{prefix} 화면에서 제거 {result.get('removed', 0)}개. "
151
+ "감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
152
+ )
153
+ self.append_audit_event(
154
+ "clear_command",
155
+ user_email=effective_email,
156
+ user_nickname=req.user_nickname,
157
+ source=req.source or "web",
158
+ conversation_id=req.conversation_id,
159
+ command=command,
160
+ scope=clear_scope,
161
+ removed=result.get("removed", 0),
162
+ kept=result.get("kept", 0),
163
+ )
164
+ self.notify("user", req.message, req.source)
165
+ self.notify("assistant", answer, req.source)
166
+ return single_answer_response(req, answer, model="history")
167
+
168
+ async def current_url(
169
+ self,
170
+ req: Any,
171
+ *,
172
+ history_meta: Dict[str, Any],
173
+ history_user: Dict[str, Any],
174
+ ):
175
+ answer = f"현재 페이지 URL: {req.client_url}"
176
+ await self.chat_service.persist_exchange(
177
+ request_message=req.message,
178
+ stored_user_message=req.message,
179
+ answer=answer,
180
+ source=req.source,
181
+ history_meta=history_meta,
182
+ history_user=history_user,
183
+ notify=self.notify,
184
+ )
185
+ return single_answer_response(req, answer, model="client_url")
186
+
187
+ async def direct_file_action(self, req: Any, *, model_id: Optional[str]):
188
+ # An explicit path ("report.txt") wins; otherwise infer one from an
189
+ # explicit type keyword ("html 파일 만들어줘") so weak models never
190
+ # have to drive the JSON tool loop just to create a file.
191
+ target_path = file_action_target(req.message) or infer_file_target(req.message)
192
+ if not target_path:
193
+ return None
194
+ content = inline_file_action_content(req.message)
195
+ generation_meta: Optional[Dict[str, Any]] = None
196
+ if content is None and not model_id:
197
+ return self.no_model_response()
198
+ if content is None and model_id:
199
+ # Model-agnostic pipeline: strict extension-aware prompt →
200
+ # extraction → validation → one corrective retry → deterministic
201
+ # repair. Guarantees a structurally valid file from any LLM.
202
+ async def _generate(context: str) -> str:
203
+ return str(
204
+ await self.router.generate_as(
205
+ model_id,
206
+ message="Return only the requested file content.",
207
+ context=context,
208
+ max_tokens=max(int(req.max_tokens or 0), 4096),
209
+ temperature=min(req.temperature, 0.3),
210
+ )
211
+ )
212
+
213
+ content, generation_meta = await generate_file_content(
214
+ _generate,
215
+ target_path=target_path,
216
+ user_request=req.message,
217
+ )
218
+ if content is None:
219
+ raise HTTPException(
220
+ status_code=400,
221
+ detail="File content could not be generated.",
222
+ )
223
+ try:
224
+ result = self.execute_tool(
225
+ "write_file",
226
+ {"path": target_path, "content": content},
227
+ )
228
+ except self.tool_error as exc:
229
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
230
+ answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
231
+ if generation_meta and generation_meta.get("repaired"):
232
+ answer += (
233
+ " (모델 출력이 불완전해 자동 보정을 거쳐 유효한 파일로 저장했습니다.)"
234
+ )
235
+ created_files = [
236
+ {
237
+ "path": result.get("path") or target_path,
238
+ "filename": Path(result.get("path") or target_path).name,
239
+ "bytes": result.get("bytes", 0),
240
+ "action": "write_file",
241
+ }
242
+ ]
243
+ self.notify("user", req.message, req.source)
244
+ self.notify("assistant", answer, req.source)
245
+ payload = {
246
+ "status": "ok",
247
+ "response": answer,
248
+ "workspace": str(self.agent_root),
249
+ "steps": [
250
+ {
251
+ "state": AgentState.EXECUTING.value,
252
+ "action": "write_file",
253
+ "args": {"path": target_path},
254
+ "result": result,
255
+ }
256
+ ],
257
+ "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
258
+ "final_state": AgentState.DONE.value,
259
+ "created_files": created_files,
260
+ "routed_to_agent": True,
261
+ "action_route": "direct_write_file",
262
+ }
263
+ if generation_meta is not None:
264
+ payload["generation"] = generation_meta
265
+ if req.stream:
266
+ return StreamingResponse(
267
+ agent_payload_stream(
268
+ answer,
269
+ payload,
270
+ router=self.router,
271
+ model_id=model_id,
272
+ ),
273
+ media_type="text/event-stream",
274
+ headers={"X-Model": model_id or "tool", "X-Routed-To": "agent"},
275
+ )
276
+ return JSONResponse(content=payload)
277
+
278
+ async def route_file_to_agent(
279
+ self,
280
+ req: Any,
281
+ request: Request,
282
+ *,
283
+ effective_email: Optional[str],
284
+ workspace_id: Optional[str],
285
+ model_id: Optional[str],
286
+ ):
287
+ agent_request = AgentRequest(
288
+ message=req.message,
289
+ conversation_id=req.conversation_id,
290
+ source=req.source or "web",
291
+ max_steps=25,
292
+ temperature=min(req.temperature, 0.2),
293
+ user_email=effective_email,
294
+ user_nickname=req.user_nickname,
295
+ workspace_id=workspace_id,
296
+ )
297
+ result = await self.agent_controller.agent(agent_request, request)
298
+ answer = str(result.get("response") or "파일 작업을 처리했습니다.")
299
+ self.notify("user", req.message, req.source)
300
+ self.notify("assistant", answer, req.source)
301
+ result["routed_to_agent"] = True
302
+ if req.stream:
303
+ return StreamingResponse(
304
+ agent_payload_stream(
305
+ answer,
306
+ result,
307
+ router=self.router,
308
+ model_id=model_id,
309
+ ),
310
+ media_type="text/event-stream",
311
+ headers={"X-Model": model_id or "agent", "X-Routed-To": "agent"},
312
+ )
313
+ return JSONResponse(content=result)
314
+
315
+
316
+ __all__ = ["ChatIntentController"]
@@ -0,0 +1,115 @@
1
+ """SSE framing and streamed-answer finalization for chat routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any, AsyncIterator, Dict, Optional
8
+
9
+ from fastapi.responses import JSONResponse, StreamingResponse
10
+
11
+ from latticeai.api.chat_helpers import single_text_stream
12
+
13
+
14
+ def single_answer_response(req: Any, answer: str, *, model: str):
15
+ if req.stream:
16
+ return StreamingResponse(
17
+ single_text_stream(answer),
18
+ media_type="text/event-stream",
19
+ headers={"X-Model": model},
20
+ )
21
+ return JSONResponse(content={"response": answer})
22
+
23
+
24
+ def agent_payload_stream(
25
+ answer: str,
26
+ payload: Dict[str, Any],
27
+ *,
28
+ router: Any,
29
+ model_id: Optional[str] = None,
30
+ ) -> AsyncIterator[str]:
31
+ async def _stream() -> AsyncIterator[str]:
32
+ response_model = model_id or router.current_model_id
33
+ yield f"data: {json.dumps({'chunk': answer, 'model': response_model, 'agent': payload}, ensure_ascii=False)}\n\n"
34
+ yield f"data: {json.dumps({'chunk': '', 'model': response_model, 'agent': payload}, ensure_ascii=False)}\n\n"
35
+ yield "data: [DONE]\n\n"
36
+
37
+ return _stream()
38
+
39
+
40
+ async def stream_chat(
41
+ req: Any,
42
+ context: str,
43
+ image_data: Optional[str],
44
+ *,
45
+ router: Any,
46
+ chat_service: Any,
47
+ knowledge_graph: Any,
48
+ enable_graph: bool,
49
+ notify: Any,
50
+ trace_seed: Optional[Dict[str, Any]] = None,
51
+ effective_email: Optional[str] = None,
52
+ history_meta: Optional[Dict[str, Any]] = None,
53
+ model_id: Optional[str] = None,
54
+ workspace_id: Optional[str] = None,
55
+ ) -> AsyncIterator[str]:
56
+ """Stream model chunks and persist exactly one finalized answer."""
57
+
58
+ full_response = ""
59
+ stream_error: Optional[str] = None
60
+ try:
61
+ async for chunk in router.stream_generate_as(
62
+ model_id,
63
+ req.message,
64
+ context,
65
+ req.max_tokens,
66
+ req.temperature,
67
+ image_data,
68
+ ):
69
+ clean_chunk = chunk.text if hasattr(chunk, "text") else chunk
70
+ full_response += str(clean_chunk)
71
+ yield f"data: {json.dumps({'chunk': clean_chunk, 'model': model_id}, ensure_ascii=False)}\n\n"
72
+ except Exception as exc:
73
+ stream_error = str(exc)
74
+ logging.warning("chat stream failed: %s", exc)
75
+ yield f"data: {json.dumps({'error': stream_error, 'model': model_id}, ensure_ascii=False)}\n\n"
76
+
77
+ persisted_response = full_response
78
+ if stream_error and not persisted_response:
79
+ persisted_response = f"[stream_error] {stream_error}"
80
+ elif stream_error:
81
+ persisted_response = f"{persisted_response}\n\n[stream_error] {stream_error}"
82
+
83
+ trace_record = None
84
+ try:
85
+ answer_trace = trace_seed or chat_service.build_graph_trace(
86
+ req.message,
87
+ knowledge_graph if (enable_graph and knowledge_graph) else None,
88
+ context,
89
+ allowed_workspaces={workspace_id} if workspace_id else None,
90
+ )
91
+ trace_record = await chat_service.persist_answer(
92
+ question=req.message,
93
+ response=persisted_response,
94
+ conversation_id=req.conversation_id,
95
+ user_email=effective_email or req.user_email,
96
+ user_nickname=req.user_nickname,
97
+ source=req.source,
98
+ trace=answer_trace,
99
+ workspace_id=workspace_id,
100
+ history_meta=history_meta or {},
101
+ notify=notify,
102
+ )
103
+ except Exception as exc:
104
+ logging.warning("chat stream persistence failed: %s", exc)
105
+
106
+ trailer: Dict[str, Any] = {"chunk": "", "model": model_id}
107
+ if trace_record:
108
+ trailer.update({"trace_id": trace_record["id"], "trace": trace_record})
109
+ if stream_error:
110
+ trailer["error"] = stream_error
111
+ yield f"data: {json.dumps(trailer, ensure_ascii=False)}\n\n"
112
+ yield "data: [DONE]\n\n"
113
+
114
+
115
+ __all__ = ["agent_payload_stream", "single_answer_response", "stream_chat"]