ltcai 9.0.0 → 9.1.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 (187) hide show
  1. package/README.md +80 -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 +146 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +3 -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 +24 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +302 -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 +19 -9
  71. package/latticeai/core/agent_registry.py +1 -5
  72. package/latticeai/core/config.py +23 -3
  73. package/latticeai/core/context_builder.py +21 -3
  74. package/latticeai/core/invitations.py +1 -4
  75. package/latticeai/core/io_utils.py +9 -1
  76. package/latticeai/core/legacy_compatibility.py +24 -3
  77. package/latticeai/core/marketplace.py +1 -1
  78. package/latticeai/core/plugins.py +15 -0
  79. package/latticeai/core/policy.py +1 -1
  80. package/latticeai/core/realtime.py +38 -15
  81. package/latticeai/core/sessions.py +7 -2
  82. package/latticeai/core/timeutil.py +10 -0
  83. package/latticeai/core/tool_registry.py +90 -18
  84. package/latticeai/core/users.py +1 -5
  85. package/latticeai/core/workspace_graph_trace.py +31 -5
  86. package/latticeai/core/workspace_memory.py +3 -1
  87. package/latticeai/core/workspace_os.py +18 -7
  88. package/latticeai/core/workspace_os_utils.py +0 -5
  89. package/latticeai/core/workspace_permissions.py +2 -1
  90. package/latticeai/core/workspace_plugins.py +1 -1
  91. package/latticeai/core/workspace_runs.py +14 -5
  92. package/latticeai/core/workspace_skills.py +1 -1
  93. package/latticeai/core/workspace_snapshots.py +2 -1
  94. package/latticeai/core/workspace_timeline.py +2 -1
  95. package/latticeai/integrations/telegram_bot.py +94 -43
  96. package/latticeai/models/router.py +130 -36
  97. package/latticeai/runtime/access_runtime.py +62 -4
  98. package/latticeai/runtime/automation_runtime.py +13 -7
  99. package/latticeai/runtime/brain_runtime.py +19 -7
  100. package/latticeai/runtime/chat_wiring.py +2 -0
  101. package/latticeai/runtime/config_runtime.py +58 -26
  102. package/latticeai/runtime/context_runtime.py +16 -4
  103. package/latticeai/runtime/hooks_runtime.py +1 -1
  104. package/latticeai/runtime/model_wiring.py +33 -5
  105. package/latticeai/runtime/namespace_runtime.py +62 -72
  106. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  107. package/latticeai/runtime/review_wiring.py +1 -1
  108. package/latticeai/runtime/router_registration.py +34 -1
  109. package/latticeai/runtime/security_runtime.py +110 -13
  110. package/latticeai/runtime/stages.py +27 -0
  111. package/latticeai/server_app.py +5 -1
  112. package/latticeai/services/architecture_readiness.py +74 -5
  113. package/latticeai/services/brain_automation.py +3 -26
  114. package/latticeai/services/chat_service.py +205 -15
  115. package/latticeai/services/local_knowledge.py +423 -0
  116. package/latticeai/services/memory_service.py +55 -21
  117. package/latticeai/services/model_engines.py +48 -33
  118. package/latticeai/services/model_errors.py +17 -0
  119. package/latticeai/services/model_loading.py +28 -25
  120. package/latticeai/services/model_runtime.py +228 -162
  121. package/latticeai/services/p_reinforce.py +22 -3
  122. package/latticeai/services/platform_runtime.py +83 -23
  123. package/latticeai/services/product_readiness.py +19 -17
  124. package/latticeai/services/review_queue.py +12 -0
  125. package/latticeai/services/router_context.py +1 -0
  126. package/latticeai/services/run_executor.py +4 -9
  127. package/latticeai/services/search_service.py +203 -28
  128. package/latticeai/services/tool_dispatch.py +28 -5
  129. package/latticeai/services/triggers.py +53 -4
  130. package/latticeai/services/upload_service.py +13 -2
  131. package/latticeai/setup/__init__.py +25 -0
  132. package/latticeai/setup/auto_setup.py +857 -0
  133. package/latticeai/setup/wizard.py +1264 -0
  134. package/latticeai/tools/__init__.py +278 -0
  135. package/{tools → latticeai/tools}/commands.py +67 -7
  136. package/{tools → latticeai/tools}/computer.py +1 -1
  137. package/{tools → latticeai/tools}/documents.py +1 -1
  138. package/{tools → latticeai/tools}/filesystem.py +3 -3
  139. package/latticeai/tools/knowledge.py +178 -0
  140. package/{tools → latticeai/tools}/local_files.py +1 -1
  141. package/{tools → latticeai/tools}/network.py +0 -1
  142. package/local_knowledge_api.py +4 -342
  143. package/package.json +22 -2
  144. package/scripts/brain_quality_eval.py +3 -1
  145. package/scripts/bump_version.py +3 -0
  146. package/scripts/capture_release_evidence.mjs +180 -0
  147. package/scripts/check_current_release_docs.mjs +142 -0
  148. package/scripts/check_i18n_literals.mjs +107 -31
  149. package/scripts/check_openapi_drift.mjs +56 -0
  150. package/scripts/export_openapi.py +67 -7
  151. package/scripts/i18n_literal_allowlist.json +22 -24
  152. package/scripts/run_integration_tests.mjs +72 -10
  153. package/scripts/validate_release_artifacts.py +49 -7
  154. package/scripts/wheel_smoke.py +5 -0
  155. package/setup_wizard.py +3 -1260
  156. package/src-tauri/Cargo.lock +1 -1
  157. package/src-tauri/Cargo.toml +1 -1
  158. package/src-tauri/tauri.conf.json +1 -1
  159. package/static/app/asset-manifest.json +11 -11
  160. package/static/app/assets/Act-Bzz0bUyW.js +1 -0
  161. package/static/app/assets/Brain-Dj2J20YA.js +321 -0
  162. package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
  163. package/static/app/assets/Library-B03FP1Yx.js +1 -0
  164. package/static/app/assets/System-80lHW0Ux.js +1 -0
  165. package/static/app/assets/index-BiMofBTM.js +17 -0
  166. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  167. package/static/app/assets/primitives-Q1A96_7v.js +1 -0
  168. package/static/app/assets/textarea-D13RtnTo.js +1 -0
  169. package/static/app/index.html +2 -2
  170. package/static/sw.js +1 -1
  171. package/tools/__init__.py +21 -269
  172. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  173. package/latticeai/runtime/app_context_runtime.py +0 -13
  174. package/latticeai/runtime/tail_wiring.py +0 -21
  175. package/scripts/com.pts.claudecode.discord.plist +0 -31
  176. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  177. package/scripts/start-pts-claudecode-discord.sh +0 -51
  178. package/static/app/assets/Act-21lIXx2E.js +0 -1
  179. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  180. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  181. package/static/app/assets/Library-bFMtyni3.js +0 -1
  182. package/static/app/assets/System-K6krGCqn.js +0 -1
  183. package/static/app/assets/index-C4R3ws30.js +0 -17
  184. package/static/app/assets/index-ChSeOB02.css +0 -2
  185. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  186. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  187. 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:
@@ -205,8 +212,24 @@ def build_recent_chat_context(
205
212
  include_image_missing_replies: bool = True,
206
213
  user_email: Optional[str] = None,
207
214
  conversation_id: Optional[str] = None,
215
+ workspace_id: Optional[str] = None,
208
216
  ) -> str:
209
- history = get_history()
217
+ if user_email:
218
+ # Apply identity/workspace isolation in the durable query rather than
219
+ # relying only on positional post-filtering. In particular, never
220
+ # admit legacy-global rows into an authenticated model prompt.
221
+ history = get_history(
222
+ user_email=user_email,
223
+ allowed_workspaces={workspace_id} if workspace_id is not None else None,
224
+ include_legacy_global=False,
225
+ )
226
+ else:
227
+ history = get_history()
228
+ if workspace_id is not None:
229
+ history = [
230
+ item for item in history
231
+ if str(item.get("workspace_id") or "personal") == str(workspace_id)
232
+ ]
210
233
  if conversation_id:
211
234
  history = [item for item in history if item.get("conversation_id") == conversation_id]
212
235
  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,302 @@
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
+ strip_generated_file_content,
18
+ )
19
+ from latticeai.api.chat_stream import agent_payload_stream, single_answer_response
20
+ from latticeai.core.agent import AgentState
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
+ target_path = file_action_target(req.message)
189
+ if not target_path:
190
+ return None
191
+ content = inline_file_action_content(req.message)
192
+ if content is None and not model_id:
193
+ return self.no_model_response()
194
+ if content is None and model_id:
195
+ generation_context = (
196
+ "Create the exact content for the requested file. "
197
+ "Return only the file bytes as plain text. "
198
+ "Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
199
+ f"Target path: {target_path}\n"
200
+ f"User request: {req.message}"
201
+ )
202
+ raw_content = await self.router.generate_as(
203
+ model_id,
204
+ message="Return only the requested file content.",
205
+ context=generation_context,
206
+ max_tokens=req.max_tokens,
207
+ temperature=req.temperature,
208
+ )
209
+ content = strip_generated_file_content(str(raw_content))
210
+ if content is None:
211
+ raise HTTPException(
212
+ status_code=400,
213
+ detail="File content could not be generated.",
214
+ )
215
+ try:
216
+ result = self.execute_tool(
217
+ "write_file",
218
+ {"path": target_path, "content": content},
219
+ )
220
+ except self.tool_error as exc:
221
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
222
+ answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
223
+ created_files = [
224
+ {
225
+ "path": result.get("path") or target_path,
226
+ "filename": Path(result.get("path") or target_path).name,
227
+ "bytes": result.get("bytes", 0),
228
+ "action": "write_file",
229
+ }
230
+ ]
231
+ self.notify("user", req.message, req.source)
232
+ self.notify("assistant", answer, req.source)
233
+ payload = {
234
+ "status": "ok",
235
+ "response": answer,
236
+ "workspace": str(self.agent_root),
237
+ "steps": [
238
+ {
239
+ "state": AgentState.EXECUTING.value,
240
+ "action": "write_file",
241
+ "args": {"path": target_path},
242
+ "result": result,
243
+ }
244
+ ],
245
+ "state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
246
+ "final_state": AgentState.DONE.value,
247
+ "created_files": created_files,
248
+ "routed_to_agent": True,
249
+ "action_route": "direct_write_file",
250
+ }
251
+ if req.stream:
252
+ return StreamingResponse(
253
+ agent_payload_stream(
254
+ answer,
255
+ payload,
256
+ router=self.router,
257
+ model_id=model_id,
258
+ ),
259
+ media_type="text/event-stream",
260
+ headers={"X-Model": model_id or "tool", "X-Routed-To": "agent"},
261
+ )
262
+ return JSONResponse(content=payload)
263
+
264
+ async def route_file_to_agent(
265
+ self,
266
+ req: Any,
267
+ request: Request,
268
+ *,
269
+ effective_email: Optional[str],
270
+ workspace_id: Optional[str],
271
+ model_id: Optional[str],
272
+ ):
273
+ agent_request = AgentRequest(
274
+ message=req.message,
275
+ conversation_id=req.conversation_id,
276
+ source=req.source or "web",
277
+ max_steps=25,
278
+ temperature=min(req.temperature, 0.2),
279
+ user_email=effective_email,
280
+ user_nickname=req.user_nickname,
281
+ workspace_id=workspace_id,
282
+ )
283
+ result = await self.agent_controller.agent(agent_request, request)
284
+ answer = str(result.get("response") or "파일 작업을 처리했습니다.")
285
+ self.notify("user", req.message, req.source)
286
+ self.notify("assistant", answer, req.source)
287
+ result["routed_to_agent"] = True
288
+ if req.stream:
289
+ return StreamingResponse(
290
+ agent_payload_stream(
291
+ answer,
292
+ result,
293
+ router=self.router,
294
+ model_id=model_id,
295
+ ),
296
+ media_type="text/event-stream",
297
+ headers={"X-Model": model_id or "agent", "X-Routed-To": "agent"},
298
+ )
299
+ return JSONResponse(content=result)
300
+
301
+
302
+ __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"]
@@ -13,7 +13,7 @@ from pydantic import BaseModel
13
13
  from latticeai.core.agent import extract_action as _extract_agent_action
14
14
  from lattice_brain.runtime.hooks import dispatch_tool
15
15
  from latticeai.services.tool_dispatch import enforce_tool_policy
16
- from tools import (
16
+ from latticeai.tools import (
17
17
  AGENT_ROOT,
18
18
  ToolError,
19
19
  computer_click,
@@ -118,9 +118,39 @@ def create_computer_use_router(
118
118
  save_to_history,
119
119
  hooks=None,
120
120
  append_audit_event=None,
121
+ workspace_service=None,
121
122
  ) -> APIRouter:
122
123
  router = APIRouter()
123
124
 
125
+ def _requested_workspace(request: Request) -> Optional[str]:
126
+ header = request.headers.get("X-Workspace-Id")
127
+ if header and header.strip():
128
+ return header.strip()
129
+ query = request.query_params.get("workspace_id")
130
+ return query.strip() if query and query.strip() else None
131
+
132
+ def _write_workspace(request: Request, current_user: str) -> Optional[str]:
133
+ """Resolve only the CU agent's durable write scope.
134
+
135
+ The other ``/cu`` actions remain host operations and do not acquire a
136
+ workspace dependency. A no-auth local request without an explicit
137
+ scope preserves its legacy unscoped history behavior; explicit scopes
138
+ and every authenticated request go through the normal write gate.
139
+ """
140
+
141
+ requested = _requested_workspace(request)
142
+ if workspace_service is None:
143
+ return requested
144
+ if not current_user and requested is None:
145
+ return None
146
+ try:
147
+ return workspace_service.resolve_write_scope(
148
+ requested,
149
+ current_user or None,
150
+ )
151
+ except PermissionError as exc:
152
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
153
+
124
154
  def _audit_args(name: str, args: dict) -> dict:
125
155
  """Return audit-safe argument metadata without storing typed text."""
126
156
  args = dict(args or {})
@@ -171,13 +201,25 @@ def create_computer_use_router(
171
201
 
172
202
  @router.get("/tools/chrome_status")
173
203
  async def tools_chrome_status(request: Request):
174
- require_user(request)
175
- return tool_response(desktop_bridge_status)
204
+ current_user = require_user(request)
205
+ result = _dispatch(
206
+ "chrome_status",
207
+ {},
208
+ desktop_bridge_status,
209
+ current_user=current_user,
210
+ )
211
+ return _response(result)
176
212
 
177
213
  @router.get("/tools/computer_use_status")
178
214
  async def tools_computer_use_status(request: Request):
179
- require_user(request)
180
- return tool_response(computer_status)
215
+ current_user = require_user(request)
216
+ result = _dispatch(
217
+ "computer_use_status",
218
+ {},
219
+ computer_status,
220
+ current_user=current_user,
221
+ )
222
+ return _response(result)
181
223
 
182
224
  @router.get("/cu/status")
183
225
  async def cu_status(request: Request):
@@ -294,6 +336,17 @@ def create_computer_use_router(
294
336
  @router.post("/cu/agent")
295
337
  async def cu_agent(req: CuAgentRequest, request: Request):
296
338
  current_user = require_user(request)
339
+ workspace_id = _write_workspace(request, current_user)
340
+
341
+ def _save_completed(role: str, message: str) -> None:
342
+ save_to_history(
343
+ role,
344
+ message,
345
+ source="web",
346
+ conversation_id=req.conversation_id,
347
+ user_email=current_user,
348
+ workspace_id=workspace_id,
349
+ )
297
350
 
298
351
  async def _stream():
299
352
  task_lower = (req.task or "").lower()
@@ -336,8 +389,8 @@ def create_computer_use_router(
336
389
  yield _send("result", {"step": 1, "action": "computer_open_app", "result": result})
337
390
  message = "Google Chrome을 열었습니다."
338
391
  action_name = "computer_open_app"
339
- save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
340
- save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
392
+ _save_completed("user", req.task)
393
+ _save_completed("assistant", message)
341
394
  yield _send("final", {"message": message, "steps": [{"step": 1, "action": action_name, "result": result}]})
342
395
  except HTTPException as exc:
343
396
  yield _send("tool_error", {"step": 1, "action": "computer_open_app", "error": str(exc.detail)})
@@ -378,8 +431,8 @@ def create_computer_use_router(
378
431
  args = action.get("args") or {}
379
432
  if name == "final":
380
433
  message = action.get("message", "작업을 완료했습니다.")
381
- save_to_history("user", req.task, source="web", conversation_id=req.conversation_id)
382
- save_to_history("assistant", message, source="web", conversation_id=req.conversation_id)
434
+ _save_completed("user", req.task)
435
+ _save_completed("assistant", message)
383
436
  yield _send("final", {"message": message, "steps": transcript})
384
437
  return
385
438