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.
- package/README.md +80 -46
- package/auto_setup.py +7 -853
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +146 -2
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +1 -6
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/graph/_kg_fsutil.py +1 -5
- package/lattice_brain/graph/discovery_index.py +174 -20
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +140 -39
- package/lattice_brain/graph/retrieval_docgen.py +37 -4
- package/lattice_brain/ingestion.py +4 -7
- package/lattice_brain/portability.py +28 -14
- package/lattice_brain/runtime/agent_runtime.py +5 -9
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +20 -2
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +320 -850
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +24 -1
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +302 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +62 -9
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +67 -26
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +96 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +110 -52
- package/latticeai/core/agent.py +19 -9
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +9 -1
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +1 -5
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +0 -5
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +94 -43
- package/latticeai/models/router.py +130 -36
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/automation_runtime.py +13 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +62 -72
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +55 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +83 -23
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +12 -0
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +4 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +1 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1260
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/Act-Bzz0bUyW.js +1 -0
- package/static/app/assets/Brain-Dj2J20YA.js +321 -0
- package/static/app/assets/Capture-CqlEl1Ga.js +1 -0
- package/static/app/assets/Library-B03FP1Yx.js +1 -0
- package/static/app/assets/System-80lHW0Ux.js +1 -0
- package/static/app/assets/index-BiMofBTM.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-Q1A96_7v.js +1 -0
- package/static/app/assets/textarea-D13RtnTo.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-21lIXx2E.js +0 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
- package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
- package/static/app/assets/Library-bFMtyni3.js +0 -1
- package/static/app/assets/System-K6krGCqn.js +0 -1
- package/static/app/assets/index-C4R3ws30.js +0 -17
- package/static/app/assets/index-ChSeOB02.css +0 -2
- package/static/app/assets/primitives-sQU3it5I.js +0 -1
- package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
|
|
382
|
-
|
|
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
|
|