ltcai 8.9.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 +81 -58
- package/auto_setup.py +7 -904
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +221 -238
- 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/ROADMAP_RECOMMENDATIONS.md +61 -36
- 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 +4 -14
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +27 -462
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +293 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +1126 -0
- 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 +141 -610
- package/lattice_brain/graph/retrieval_docgen.py +243 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +69 -4
- package/lattice_brain/portability.py +29 -23
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +169 -7
- 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 +46 -0
- 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 +355 -952
- 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 +250 -0
- 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 +211 -40
- 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/marketplace.py +11 -0
- 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 +70 -29
- 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 +97 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +185 -405
- 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 +45 -0
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/local_embeddings.py +2 -4
- package/latticeai/core/marketplace.py +33 -2
- package/latticeai/core/mcp_catalog.py +450 -0
- package/latticeai/core/mcp_registry.py +2 -441
- 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 +5 -14
- 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 +2 -21
- 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 +96 -40
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +189 -173
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +22 -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/history_runtime.py +163 -0
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +163 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- 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/sso_config_runtime.py +128 -0
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/runtime/user_key_runtime.py +106 -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 +268 -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 +92 -24
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +76 -11
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +25 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/setup_detection.py +80 -0
- 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 +7 -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 -1304
- 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/css/tokens.css +4 -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/sso_runtime.py +0 -52
- 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-fZokUnC0.js +0 -1
- package/static/app/assets/Brain-DtyuWubr.js +0 -321
- package/static/app/assets/Capture-D5KV3Cu7.js +0 -1
- package/static/app/assets/Library-C9kyFkSt.js +0 -1
- package/static/app/assets/System-VbChmX7r.js +0 -1
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/index-DPdcPoF0.js +0 -17
- package/static/app/assets/primitives-DFeanEV6.js +0 -1
- package/static/app/assets/textarea-CD8UNKIy.js +0 -1
- package/tools/knowledge.py +0 -95
package/latticeai/api/chat.py
CHANGED
|
@@ -1,1080 +1,483 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Composition root for chat, history, document, and local-agent routes.
|
|
2
|
+
|
|
3
|
+
The stable public factory remains ``create_chat_router(AppContext)``. Feature
|
|
4
|
+
implementation is split across focused HTTP modules while this file owns only
|
|
5
|
+
dependency assembly and the top-level chat request pipeline.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from __future__ import annotations
|
|
4
9
|
|
|
5
|
-
import asyncio
|
|
6
|
-
import base64
|
|
7
|
-
import io
|
|
8
|
-
import json
|
|
9
10
|
import logging
|
|
10
11
|
import re
|
|
11
|
-
import secrets
|
|
12
|
-
import shutil
|
|
13
|
-
import subprocess
|
|
14
|
-
import tempfile
|
|
15
|
-
import threading
|
|
16
12
|
from pathlib import Path
|
|
17
|
-
from typing import
|
|
13
|
+
from typing import Dict, Optional
|
|
18
14
|
|
|
19
15
|
from fastapi import APIRouter, HTTPException, Request
|
|
20
16
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
21
|
-
from pydantic import BaseModel
|
|
22
|
-
from PIL import Image
|
|
23
17
|
|
|
24
|
-
from latticeai.
|
|
25
|
-
from latticeai.
|
|
26
|
-
|
|
27
|
-
|
|
18
|
+
from latticeai.api.chat_agent_http import AgentHTTPController
|
|
19
|
+
from latticeai.api.chat_contracts import (
|
|
20
|
+
AgentEvalRequest,
|
|
21
|
+
AgentRequest,
|
|
22
|
+
AgentResumeRequest,
|
|
23
|
+
ChatRequest,
|
|
24
|
+
)
|
|
25
|
+
from latticeai.api.chat_documents import (
|
|
26
|
+
DocumentGenerationCoordinator,
|
|
27
|
+
extract_screenshot_context,
|
|
28
|
+
)
|
|
29
|
+
from latticeai.api.chat_helpers import (
|
|
30
|
+
_LANG_HINT,
|
|
31
|
+
build_recent_chat_context,
|
|
32
|
+
detect_language,
|
|
33
|
+
file_action_target,
|
|
34
|
+
format_network_status,
|
|
35
|
+
inline_file_action_content,
|
|
36
|
+
is_clear_command,
|
|
37
|
+
is_current_url_request,
|
|
38
|
+
is_file_action_request,
|
|
39
|
+
is_network_status_request,
|
|
40
|
+
pair_user_history,
|
|
41
|
+
single_text_stream,
|
|
42
|
+
strip_generated_file_content,
|
|
43
|
+
workspace_scope_from_request,
|
|
44
|
+
)
|
|
45
|
+
from latticeai.api.chat_history import HistoryRouteDependencies, register_history_routes
|
|
46
|
+
from latticeai.api.chat_intents import ChatIntentController
|
|
47
|
+
from latticeai.api.chat_stream import stream_chat
|
|
28
48
|
from latticeai.services.app_context import AppContext
|
|
29
|
-
from latticeai.services.
|
|
30
|
-
from
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
temperature: float = 0.2
|
|
39
|
-
stream: bool = True
|
|
40
|
-
context: Optional[str] = None
|
|
41
|
-
source: Optional[str] = None
|
|
42
|
-
user_email: Optional[str] = None
|
|
43
|
-
user_nickname: Optional[str] = None
|
|
44
|
-
image_data: Optional[str] = None
|
|
45
|
-
allow_file_context: bool = False
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class AgentRequest(BaseModel):
|
|
49
|
-
message: str
|
|
50
|
-
conversation_id: Optional[str] = None
|
|
51
|
-
source: Optional[str] = None
|
|
52
|
-
max_steps: int = 25
|
|
53
|
-
temperature: float = 0.1
|
|
54
|
-
user_email: Optional[str] = None
|
|
55
|
-
user_nickname: Optional[str] = None
|
|
56
|
-
workspace_id: Optional[str] = None
|
|
57
|
-
planning_model: Optional[str] = None
|
|
58
|
-
executing_model: Optional[str] = None
|
|
59
|
-
reviewing_model: Optional[str] = None
|
|
60
|
-
human_in_loop: bool = False
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class AgentResumeRequest(BaseModel):
|
|
64
|
-
context_id: str
|
|
65
|
-
approved: bool = True
|
|
66
|
-
modified_plan: Optional[dict] = None
|
|
67
|
-
executing_model: Optional[str] = None
|
|
68
|
-
reviewing_model: Optional[str] = None
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
class AgentEvalRequest(BaseModel):
|
|
72
|
-
skill: str
|
|
73
|
-
case_id: Optional[str] = None
|
|
74
|
-
|
|
75
|
-
def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
|
|
76
|
-
"""Restrict history to one user's exchange.
|
|
77
|
-
|
|
78
|
-
Keeps the user's own messages plus assistant replies that directly follow
|
|
79
|
-
them. A bare role=="assistant" pass would leak every other user's replies
|
|
80
|
-
into this user's prompt context.
|
|
81
|
-
"""
|
|
82
|
-
paired: List[Dict] = []
|
|
83
|
-
include_next_assistant = False
|
|
84
|
-
for item in history:
|
|
85
|
-
if item.get("role") == "assistant":
|
|
86
|
-
if include_next_assistant:
|
|
87
|
-
paired.append(item)
|
|
88
|
-
include_next_assistant = False
|
|
89
|
-
elif item.get("user_email") == user_email:
|
|
90
|
-
paired.append(item)
|
|
91
|
-
include_next_assistant = True
|
|
92
|
-
else:
|
|
93
|
-
include_next_assistant = False
|
|
94
|
-
return paired
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def detect_language(text: str) -> str:
|
|
98
|
-
"""Detect language: 'ko' (Korean) or 'en' (English)."""
|
|
99
|
-
total = max(len(text), 1)
|
|
100
|
-
ko = sum(1 for c in text if '가' <= c <= '힣')
|
|
101
|
-
if ko / total > 0.05:
|
|
102
|
-
return "ko"
|
|
103
|
-
return "en"
|
|
104
|
-
|
|
105
|
-
_LANG_HINT = {
|
|
106
|
-
"ko": "Respond in Korean (한국어로 답변하세요).",
|
|
107
|
-
"en": "Respond in English.",
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
def is_network_status_request(text: str) -> bool:
|
|
111
|
-
"""사용자가 현재 IP/네트워크 정보를 물었는지 감지합니다."""
|
|
112
|
-
t = (text or "").lower()
|
|
113
|
-
has_ip = bool(re.search(r"((?<![a-z0-9])ip(?![a-z0-9])|아이피|ip\s*주소|아이피\s*주소|ipconfig|ifconfig|네트워크)", t))
|
|
114
|
-
asks_current = any(word in t for word in ["내", "현재", "지금", "local", "로컬", "주소", "address", "뭐", "알려", "확인", "상태"])
|
|
115
|
-
return has_ip and asks_current
|
|
116
|
-
|
|
117
|
-
def is_current_url_request(text: str) -> bool:
|
|
118
|
-
t = (text or "").lower()
|
|
119
|
-
has_url = any(word in t for word in ["url", "주소", "링크", "address"])
|
|
120
|
-
asks_current = any(word in t for word in ["현재", "지금", "여기", "접속", "페이지", "브라우저", "알려", "뭐"])
|
|
121
|
-
return has_url and asks_current
|
|
122
|
-
|
|
123
|
-
def is_clear_command(text: str) -> bool:
|
|
124
|
-
return (text or "").strip().lower() in {"/clear", "/clear_all"}
|
|
125
|
-
|
|
126
|
-
# Path segments intentionally exclude spaces: allowing spaces let the target
|
|
127
|
-
# match swallow preceding words (e.g. "create a text file report.txt" resolved
|
|
128
|
-
# to the whole phrase as the path). Chat file targets are single tokens.
|
|
129
|
-
_FILE_TARGET_RE = re.compile(
|
|
130
|
-
r"(?<![\w.-])(?:[~./\\]?[\w.@()+-]+[\\/])*[\w.@()+-]+"
|
|
131
|
-
r"\.(?:py|js|jsx|ts|tsx|md|markdown|txt|json|yaml|yml|toml|html|css|csv|xml|pdf|docx|xlsx|pptx|sh|sql)",
|
|
132
|
-
re.IGNORECASE,
|
|
49
|
+
from latticeai.services.chat_service import ChatService
|
|
50
|
+
from latticeai.services.tool_dispatch import build_agent_runtime, enforce_tool_policy
|
|
51
|
+
from latticeai.tools import (
|
|
52
|
+
AGENT_ROOT,
|
|
53
|
+
ToolError,
|
|
54
|
+
ensure_agent_root,
|
|
55
|
+
execute_tool,
|
|
56
|
+
knowledge_save,
|
|
57
|
+
network_status,
|
|
133
58
|
)
|
|
134
59
|
|
|
135
60
|
|
|
136
|
-
|
|
137
|
-
""
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
""
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
has_target = bool(_FILE_TARGET_RE.search(raw))
|
|
158
|
-
has_file_word = any(word in lower for word in (
|
|
159
|
-
"file", "파일", "문서", "artifact", "아티팩트", "save as", "저장",
|
|
160
|
-
))
|
|
161
|
-
has_action = any(word in lower for word in (
|
|
162
|
-
"create", "make", "write", "save", "generate", "edit", "update",
|
|
163
|
-
"만들", "생성", "작성", "저장", "수정", "써줘", "만들어줘",
|
|
164
|
-
))
|
|
165
|
-
|
|
166
|
-
if not has_action:
|
|
167
|
-
return False
|
|
168
|
-
if has_target and has_action:
|
|
169
|
-
return True
|
|
170
|
-
return has_file_word and has_action
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def file_action_target(text: str) -> Optional[str]:
|
|
174
|
-
"""Extract the first explicit workspace file target from a request."""
|
|
175
|
-
match = _FILE_TARGET_RE.search(text or "")
|
|
176
|
-
if not match:
|
|
177
|
-
return None
|
|
178
|
-
return match.group(0).strip().strip("`'\".,:;)]}")
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def inline_file_action_content(text: str) -> Optional[str]:
|
|
182
|
-
"""Extract short user-provided content for deterministic file writes."""
|
|
183
|
-
raw = (text or "").strip()
|
|
184
|
-
# Each pattern requires an explicit binder so ambiguous words like "text"
|
|
185
|
-
# ("create a text file report.txt") or a bare "with" ("report.md with a
|
|
186
|
-
# summary of X") do not get captured as literal file content.
|
|
187
|
-
patterns = [
|
|
188
|
-
r"(?:내용|본문)\s*(?:은|는|이에요|입니다)\s*(.+)$",
|
|
189
|
-
r"(?:내용|본문|content|body|text)\s*[:=]\s*(.+)$",
|
|
190
|
-
r"(?:content|body)\s+(?:is|as)\s+(.+)$",
|
|
191
|
-
r"(?:with the content|with content|containing)\s+(.+)$",
|
|
192
|
-
]
|
|
193
|
-
for pattern in patterns:
|
|
194
|
-
match = re.search(pattern, raw, flags=re.IGNORECASE | re.DOTALL)
|
|
195
|
-
if match:
|
|
196
|
-
content = match.group(1).strip()
|
|
197
|
-
return content.strip("`'\"")
|
|
198
|
-
return None
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def strip_generated_file_content(text: str) -> str:
|
|
202
|
-
"""Remove common chat wrappers when a model is asked for file content only."""
|
|
203
|
-
content = (text or "").strip()
|
|
204
|
-
fenced = re.search(r"```(?:[\w.+-]+)?\s*(.*?)\s*```", content, flags=re.DOTALL)
|
|
205
|
-
if fenced:
|
|
206
|
-
content = fenced.group(1).strip()
|
|
207
|
-
return content
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def format_network_status(info: Dict) -> str:
|
|
211
|
-
lines = [
|
|
212
|
-
f"내부 IP: {info.get('local_ip') or '확인 안 됨'}",
|
|
213
|
-
f"외부 IP: {info.get('public_ip') or '확인 안 됨'}",
|
|
214
|
-
f"호스트명: {info.get('hostname') or '확인 안 됨'}",
|
|
215
|
-
]
|
|
216
|
-
local_ips = info.get("local_ips") or {}
|
|
217
|
-
if local_ips:
|
|
218
|
-
lines.extend(["", "인터페이스:"])
|
|
219
|
-
lines.extend(f"- {name}: {ip}" for name, ip in local_ips.items())
|
|
220
|
-
note = info.get("note")
|
|
221
|
-
if note:
|
|
222
|
-
lines.extend(["", note])
|
|
223
|
-
return "\n".join(lines)
|
|
224
|
-
|
|
225
|
-
def workspace_scope_from_request(request: Request) -> Optional[str]:
|
|
226
|
-
header = request.headers.get("X-Workspace-Id")
|
|
227
|
-
if header and header.strip():
|
|
228
|
-
return header.strip()
|
|
229
|
-
query = request.query_params.get("workspace_id")
|
|
230
|
-
return query.strip() if query and query.strip() else None
|
|
231
|
-
|
|
232
|
-
async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
|
|
233
|
-
yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
|
|
234
|
-
yield "data: [DONE]\n\n"
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def build_recent_chat_context(
|
|
238
|
-
*,
|
|
239
|
-
get_history,
|
|
240
|
-
limit: int = 10,
|
|
241
|
-
include_image_missing_replies: bool = True,
|
|
242
|
-
user_email: Optional[str] = None,
|
|
243
|
-
conversation_id: Optional[str] = None,
|
|
244
|
-
) -> str:
|
|
245
|
-
history = get_history()
|
|
246
|
-
if conversation_id:
|
|
247
|
-
history = [item for item in history if item.get("conversation_id") == conversation_id]
|
|
248
|
-
if user_email:
|
|
249
|
-
history = pair_user_history(history, user_email)
|
|
250
|
-
history = history[-limit:]
|
|
251
|
-
lines = []
|
|
252
|
-
for item in history:
|
|
253
|
-
role = item.get("role", "user")
|
|
254
|
-
content = item.get("content", "")
|
|
255
|
-
if not include_image_missing_replies and role == "assistant":
|
|
256
|
-
if "이미지" in content and any(word in content for word in ["업로드", "제공", "올려"]):
|
|
257
|
-
continue
|
|
258
|
-
source = item.get("source")
|
|
259
|
-
label = role
|
|
260
|
-
if source:
|
|
261
|
-
label = f"{role} ({source})"
|
|
262
|
-
lines.append(f"{label}: {content}")
|
|
263
|
-
return "\n".join(lines)
|
|
61
|
+
__all__ = [
|
|
62
|
+
"AgentEvalRequest",
|
|
63
|
+
"AgentRequest",
|
|
64
|
+
"AgentResumeRequest",
|
|
65
|
+
"ChatRequest",
|
|
66
|
+
"create_chat_router",
|
|
67
|
+
"build_recent_chat_context",
|
|
68
|
+
"pair_user_history",
|
|
69
|
+
"detect_language",
|
|
70
|
+
"file_action_target",
|
|
71
|
+
"inline_file_action_content",
|
|
72
|
+
"is_file_action_request",
|
|
73
|
+
"is_network_status_request",
|
|
74
|
+
"is_current_url_request",
|
|
75
|
+
"is_clear_command",
|
|
76
|
+
"format_network_status",
|
|
77
|
+
"strip_generated_file_content",
|
|
78
|
+
"workspace_scope_from_request",
|
|
79
|
+
"single_text_stream",
|
|
80
|
+
]
|
|
264
81
|
|
|
265
82
|
|
|
266
83
|
def create_chat_router(context: AppContext) -> APIRouter:
|
|
267
|
-
"""Build the chat/history/agent
|
|
84
|
+
"""Build the unchanged chat/history/agent route surface."""
|
|
268
85
|
|
|
269
|
-
Replaces the historical ~25-kwarg factory signature: ``context``
|
|
270
|
-
(:class:`latticeai.services.app_context.AppContext`) carries the same
|
|
271
|
-
dependencies as typed fields.
|
|
272
|
-
"""
|
|
273
86
|
api_router = APIRouter()
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
workspace_graph = context.workspace_graph
|
|
277
|
-
context_assembler = context.context_assembler
|
|
87
|
+
model_router = context.model_router
|
|
88
|
+
config = context.config
|
|
278
89
|
require_user = context.require_user
|
|
279
|
-
|
|
280
|
-
get_history_user = context.get_history_user
|
|
281
|
-
save_to_history = context.save_to_history
|
|
282
|
-
append_audit_event = context.append_audit_event
|
|
283
|
-
clear_history = context.clear_history
|
|
284
|
-
clear_conversation = context.clear_conversation
|
|
285
|
-
get_history = context.get_history
|
|
286
|
-
group_history_conversations = context.group_history_conversations
|
|
287
|
-
get_conversation_messages = context.get_conversation_messages
|
|
288
|
-
conversation_title = context.conversation_title
|
|
90
|
+
workspace_service = context.workspace_service
|
|
289
91
|
allowed_workspaces_for = context.allowed_workspaces_for
|
|
290
92
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
_doc_gen_sessions: dict = {}
|
|
299
|
-
_pending_agents: dict[str, tuple] = {}
|
|
300
|
-
_pending_agents_lock = threading.Lock()
|
|
301
|
-
|
|
302
|
-
on_chat_message = context.on_chat_message
|
|
93
|
+
chat_service = ChatService.coerce(
|
|
94
|
+
context.chat_service,
|
|
95
|
+
store=context.workspace_store,
|
|
96
|
+
get_history=context.get_history,
|
|
97
|
+
save_to_history=context.save_to_history,
|
|
98
|
+
get_history_user=context.get_history_user,
|
|
99
|
+
)
|
|
303
100
|
|
|
304
101
|
def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
|
|
305
|
-
"""Mirror
|
|
306
|
-
|
|
307
|
-
``on_chat_message`` is registered by ``create_app`` only when
|
|
308
|
-
ENABLE_TELEGRAM is truthy; exchanges that originated *from* telegram
|
|
309
|
-
are never echoed back. No bridge registered → no-op.
|
|
310
|
-
"""
|
|
311
|
-
if on_chat_message is None or source == "telegram":
|
|
102
|
+
"""Mirror persisted web exchanges to an injected bridge, never echoing it."""
|
|
103
|
+
if context.on_chat_message is None or source == "telegram":
|
|
312
104
|
return
|
|
313
105
|
try:
|
|
314
|
-
on_chat_message(role, text, source)
|
|
106
|
+
context.on_chat_message(role, text, source)
|
|
315
107
|
except Exception as exc:
|
|
316
108
|
logging.warning("chat message bridge failed: %s", exc)
|
|
317
109
|
|
|
110
|
+
def authenticated_identity(
|
|
111
|
+
current_user: str,
|
|
112
|
+
claimed_email: Optional[str],
|
|
113
|
+
) -> Optional[str]:
|
|
114
|
+
if current_user and claimed_email:
|
|
115
|
+
if current_user.strip().lower() != claimed_email.strip().lower():
|
|
116
|
+
raise HTTPException(
|
|
117
|
+
status_code=403,
|
|
118
|
+
detail="user_email must match the authenticated user.",
|
|
119
|
+
)
|
|
120
|
+
return current_user or claimed_email or None
|
|
121
|
+
|
|
122
|
+
def write_workspace(
|
|
123
|
+
requested: Optional[str],
|
|
124
|
+
current_user: str,
|
|
125
|
+
) -> Optional[str]:
|
|
126
|
+
if workspace_service is None:
|
|
127
|
+
return requested
|
|
128
|
+
try:
|
|
129
|
+
return workspace_service.resolve_write_scope(
|
|
130
|
+
requested,
|
|
131
|
+
current_user or None,
|
|
132
|
+
)
|
|
133
|
+
except PermissionError as exc:
|
|
134
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
135
|
+
|
|
318
136
|
def history_scope_for_user(user_email: Optional[str]) -> Dict:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return {
|
|
325
|
-
"user_email": scoped_user,
|
|
326
|
-
"allowed_workspaces": allowed,
|
|
327
|
-
"include_legacy_global": not require_auth,
|
|
328
|
-
}
|
|
137
|
+
return chat_service.history_scope(
|
|
138
|
+
user_email,
|
|
139
|
+
require_auth=bool(getattr(config, "require_auth", False)),
|
|
140
|
+
allowed_workspaces_for=allowed_workspaces_for,
|
|
141
|
+
)
|
|
329
142
|
|
|
330
143
|
def recent_chat_context(
|
|
331
144
|
limit: int = 10,
|
|
332
145
|
include_image_missing_replies: bool = True,
|
|
333
146
|
user_email: Optional[str] = None,
|
|
334
147
|
conversation_id: Optional[str] = None,
|
|
148
|
+
workspace_id: Optional[str] = None,
|
|
335
149
|
) -> str:
|
|
336
150
|
return build_recent_chat_context(
|
|
337
|
-
get_history=get_history,
|
|
151
|
+
get_history=context.get_history,
|
|
338
152
|
limit=limit,
|
|
339
153
|
include_image_missing_replies=include_image_missing_replies,
|
|
340
154
|
user_email=user_email,
|
|
341
155
|
conversation_id=conversation_id,
|
|
156
|
+
workspace_id=workspace_id,
|
|
342
157
|
)
|
|
343
158
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
try:
|
|
351
|
-
image_bytes = base64.b64decode(image_data)
|
|
352
|
-
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
|
353
|
-
lines.append(f"- image_size: {image.width}x{image.height}")
|
|
354
|
-
lines.append(f"- image_mode: {image.mode}")
|
|
355
|
-
except Exception as e:
|
|
356
|
-
lines.append(f"- image_decode_error: {e}")
|
|
357
|
-
return "\n".join(lines)
|
|
358
|
-
|
|
359
|
-
tesseract_path = shutil.which("tesseract")
|
|
360
|
-
if not tesseract_path:
|
|
361
|
-
lines.append("- ocr: unavailable; install `tesseract` to enable OCR text extraction.")
|
|
362
|
-
return "\n".join(lines)
|
|
363
|
-
|
|
364
|
-
temp_path = None
|
|
365
|
-
try:
|
|
366
|
-
with tempfile.NamedTemporaryFile(prefix="ltcai-screenshot-", suffix=".png", delete=False) as temp:
|
|
367
|
-
temp.write(image_bytes)
|
|
368
|
-
temp_path = temp.name
|
|
369
|
-
|
|
370
|
-
ocr_text = ""
|
|
371
|
-
for lang in ("kor+eng", "eng"):
|
|
372
|
-
completed = subprocess.run(
|
|
373
|
-
[tesseract_path, temp_path, "stdout", "-l", lang, "--psm", "6"],
|
|
374
|
-
capture_output=True,
|
|
375
|
-
text=True,
|
|
376
|
-
timeout=20,
|
|
377
|
-
check=False,
|
|
378
|
-
)
|
|
379
|
-
if completed.returncode == 0 and completed.stdout.strip():
|
|
380
|
-
ocr_text = completed.stdout.strip()
|
|
381
|
-
lines.append(f"- ocr_language: {lang}")
|
|
382
|
-
break
|
|
383
|
-
|
|
384
|
-
if ocr_text:
|
|
385
|
-
lines.append("- ocr_text:")
|
|
386
|
-
lines.append(ocr_text[:4000])
|
|
387
|
-
else:
|
|
388
|
-
lines.append("- ocr: no text extracted.")
|
|
389
|
-
except Exception as e:
|
|
390
|
-
lines.append(f"- ocr_error: {e}")
|
|
391
|
-
finally:
|
|
392
|
-
if temp_path:
|
|
393
|
-
try:
|
|
394
|
-
Path(temp_path).unlink()
|
|
395
|
-
except OSError:
|
|
396
|
-
pass
|
|
397
|
-
|
|
398
|
-
return "\n".join(lines)
|
|
399
|
-
|
|
400
|
-
_AGENT_RUNTIME = context.chat_agent_runtime
|
|
401
|
-
if _AGENT_RUNTIME is None:
|
|
402
|
-
_AGENT_RUNTIME = build_agent_runtime(
|
|
403
|
-
model_router=router,
|
|
159
|
+
agent_runtime = context.chat_agent_runtime
|
|
160
|
+
if agent_runtime is None:
|
|
161
|
+
# Keep construction here so the historical monkeypatch/import seam on
|
|
162
|
+
# latticeai.api.chat.build_agent_runtime remains valid.
|
|
163
|
+
agent_runtime = build_agent_runtime(
|
|
164
|
+
model_router=model_router,
|
|
404
165
|
execute_tool=execute_tool,
|
|
405
166
|
recent_chat_context=recent_chat_context,
|
|
406
|
-
clear_history=clear_history,
|
|
167
|
+
clear_history=context.clear_history,
|
|
407
168
|
knowledge_save=knowledge_save,
|
|
408
|
-
audit=append_audit_event,
|
|
409
|
-
hooks=hooks,
|
|
169
|
+
audit=context.append_audit_event,
|
|
170
|
+
hooks=context.hooks,
|
|
410
171
|
brain_memory=context.brain_memory,
|
|
411
172
|
)
|
|
412
173
|
|
|
174
|
+
agent_controller = AgentHTTPController(
|
|
175
|
+
runtime=agent_runtime,
|
|
176
|
+
model_router=model_router,
|
|
177
|
+
require_user=require_user,
|
|
178
|
+
require_admin=context.require_admin,
|
|
179
|
+
enforce_rate_limit=context.enforce_rate_limit,
|
|
180
|
+
authenticated_identity=authenticated_identity,
|
|
181
|
+
write_workspace=write_workspace,
|
|
182
|
+
save_to_history=context.save_to_history,
|
|
183
|
+
workspace_store=context.workspace_store,
|
|
184
|
+
workspace_graph=context.workspace_graph,
|
|
185
|
+
hooks=context.hooks,
|
|
186
|
+
execute_tool=execute_tool,
|
|
187
|
+
base_dir=context.base_dir or Path.cwd(),
|
|
188
|
+
agent_root=AGENT_ROOT,
|
|
189
|
+
ensure_agent_root=ensure_agent_root,
|
|
190
|
+
)
|
|
191
|
+
intent_controller = ChatIntentController(
|
|
192
|
+
model_router=model_router,
|
|
193
|
+
config=config,
|
|
194
|
+
public_model=context.public_model,
|
|
195
|
+
chat_service=chat_service,
|
|
196
|
+
notify=notify_chat_message,
|
|
197
|
+
clear_history=context.clear_history,
|
|
198
|
+
clear_conversation=context.clear_conversation,
|
|
199
|
+
history_scope_for_user=history_scope_for_user,
|
|
200
|
+
append_audit_event=context.append_audit_event,
|
|
201
|
+
enable_graph=context.enable_graph,
|
|
202
|
+
knowledge_graph=context.knowledge_graph,
|
|
203
|
+
enforce_tool_policy=enforce_tool_policy,
|
|
204
|
+
network_status=network_status,
|
|
205
|
+
tool_error=ToolError,
|
|
206
|
+
execute_tool=execute_tool,
|
|
207
|
+
agent_controller=agent_controller,
|
|
208
|
+
agent_root=AGENT_ROOT,
|
|
209
|
+
)
|
|
210
|
+
document_coordinator = DocumentGenerationCoordinator(
|
|
211
|
+
model_router=model_router,
|
|
212
|
+
knowledge_graph=context.knowledge_graph,
|
|
213
|
+
enable_graph=context.enable_graph,
|
|
214
|
+
chat_service=chat_service,
|
|
215
|
+
notify=notify_chat_message,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def request_model(model_id: Optional[str]) -> Optional[str]:
|
|
219
|
+
selected = model_id or model_router.current_model_id
|
|
220
|
+
if model_id and model_id not in model_router.loaded_model_ids:
|
|
221
|
+
raise HTTPException(
|
|
222
|
+
status_code=404,
|
|
223
|
+
detail=f"Model '{model_id}' not loaded.",
|
|
224
|
+
)
|
|
225
|
+
return selected
|
|
226
|
+
|
|
413
227
|
@api_router.post("/chat")
|
|
414
228
|
async def chat(req: ChatRequest, request: Request):
|
|
415
229
|
current_user = require_user(request)
|
|
416
|
-
enforce_rate_limit(current_user, "chat")
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
230
|
+
context.enforce_rate_limit(current_user, "chat")
|
|
231
|
+
logging.debug(
|
|
232
|
+
"/chat request: stream=%s image_data_len=%s message_len=%s",
|
|
233
|
+
req.stream,
|
|
234
|
+
len(req.image_data) if req.image_data else 0,
|
|
235
|
+
len(req.message or ""),
|
|
236
|
+
)
|
|
237
|
+
effective_email = authenticated_identity(current_user, req.user_email)
|
|
238
|
+
workspace_id = write_workspace(
|
|
239
|
+
workspace_scope_from_request(request),
|
|
240
|
+
current_user,
|
|
241
|
+
)
|
|
242
|
+
history_user = chat_service.history_user(
|
|
243
|
+
effective_email,
|
|
244
|
+
req.user_nickname,
|
|
421
245
|
)
|
|
422
|
-
effective_email = req.user_email or current_user or None
|
|
423
|
-
history_user = get_history_user(effective_email, req.user_nickname)
|
|
424
246
|
history_meta = {
|
|
425
247
|
"source": req.source or "web",
|
|
426
248
|
"conversation_id": req.conversation_id,
|
|
427
|
-
"workspace_id":
|
|
249
|
+
"workspace_id": workspace_id,
|
|
428
250
|
}
|
|
429
251
|
|
|
430
252
|
if is_network_status_request(req.message):
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
save_to_history("assistant", answer, **history_meta, **history_user)
|
|
438
|
-
notify_chat_message("user", req.message, req.source)
|
|
439
|
-
notify_chat_message("assistant", answer, req.source)
|
|
440
|
-
if req.stream:
|
|
441
|
-
return StreamingResponse(
|
|
442
|
-
single_text_stream(answer),
|
|
443
|
-
media_type="text/event-stream",
|
|
444
|
-
headers={"X-Model": "network_status"},
|
|
445
|
-
)
|
|
446
|
-
return JSONResponse(content={"response": answer})
|
|
447
|
-
|
|
253
|
+
return await intent_controller.network(
|
|
254
|
+
req,
|
|
255
|
+
current_user=current_user,
|
|
256
|
+
history_meta=history_meta,
|
|
257
|
+
history_user=history_user,
|
|
258
|
+
)
|
|
448
259
|
if is_clear_command(req.message):
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
KNOWLEDGE_GRAPH.ingest_event(
|
|
454
|
-
"ClearEvent",
|
|
455
|
-
f"{command} requested",
|
|
456
|
-
user_email=effective_email,
|
|
457
|
-
user_nickname=req.user_nickname,
|
|
458
|
-
source=req.source or "web",
|
|
459
|
-
conversation_id=req.conversation_id,
|
|
460
|
-
metadata={"command": command, "scope": clear_scope},
|
|
461
|
-
)
|
|
462
|
-
except Exception as e:
|
|
463
|
-
logging.warning("knowledge graph clear event ingest failed: %s", e)
|
|
464
|
-
if command == "/clear_all":
|
|
465
|
-
result = clear_history(0, **history_scope_for_user(effective_email))
|
|
466
|
-
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
467
|
-
else:
|
|
468
|
-
if req.conversation_id:
|
|
469
|
-
result = clear_conversation(req.conversation_id, **history_scope_for_user(effective_email))
|
|
470
|
-
answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
471
|
-
else:
|
|
472
|
-
result = clear_history(0, **history_scope_for_user(effective_email))
|
|
473
|
-
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
474
|
-
append_audit_event(
|
|
475
|
-
"clear_command",
|
|
476
|
-
user_email=effective_email,
|
|
477
|
-
user_nickname=req.user_nickname,
|
|
478
|
-
source=req.source or "web",
|
|
479
|
-
conversation_id=req.conversation_id,
|
|
480
|
-
command=command,
|
|
481
|
-
scope=clear_scope,
|
|
482
|
-
removed=result.get("removed", 0),
|
|
483
|
-
kept=result.get("kept", 0),
|
|
260
|
+
return await intent_controller.clear(
|
|
261
|
+
req,
|
|
262
|
+
effective_email=effective_email,
|
|
263
|
+
workspace_id=workspace_id,
|
|
484
264
|
)
|
|
485
|
-
if req.stream:
|
|
486
|
-
return StreamingResponse(
|
|
487
|
-
single_text_stream(answer),
|
|
488
|
-
media_type="text/event-stream",
|
|
489
|
-
headers={"X-Model": "history"},
|
|
490
|
-
)
|
|
491
|
-
return JSONResponse(content={"response": answer})
|
|
492
|
-
|
|
493
265
|
if is_current_url_request(req.message) and req.client_url:
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if req.stream:
|
|
500
|
-
return StreamingResponse(
|
|
501
|
-
single_text_stream(answer),
|
|
502
|
-
media_type="text/event-stream",
|
|
503
|
-
headers={"X-Model": "client_url"},
|
|
504
|
-
)
|
|
505
|
-
return JSONResponse(content={"response": answer})
|
|
266
|
+
return await intent_controller.current_url(
|
|
267
|
+
req,
|
|
268
|
+
history_meta=history_meta,
|
|
269
|
+
history_user=history_user,
|
|
270
|
+
)
|
|
506
271
|
|
|
272
|
+
selected_model_id = request_model(req.model)
|
|
507
273
|
if is_file_action_request(req.message):
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if content is None and router.current_model_id:
|
|
512
|
-
if req.model and req.model != router.current_model_id:
|
|
513
|
-
if req.model not in router.loaded_model_ids:
|
|
514
|
-
raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
|
|
515
|
-
router.switch_model(req.model)
|
|
516
|
-
generation_context = (
|
|
517
|
-
"Create the exact content for the requested file. "
|
|
518
|
-
"Return only the file bytes as plain text. "
|
|
519
|
-
"Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
|
|
520
|
-
f"Target path: {target_path}\n"
|
|
521
|
-
f"User request: {req.message}"
|
|
522
|
-
)
|
|
523
|
-
raw_content = await router.generate_as(
|
|
524
|
-
router.current_model_id,
|
|
525
|
-
message="Return only the requested file content.",
|
|
526
|
-
context=generation_context,
|
|
527
|
-
max_tokens=req.max_tokens,
|
|
528
|
-
temperature=req.temperature,
|
|
529
|
-
)
|
|
530
|
-
content = strip_generated_file_content(str(raw_content))
|
|
531
|
-
if content is None:
|
|
532
|
-
content = ""
|
|
533
|
-
try:
|
|
534
|
-
result = execute_tool("write_file", {"path": target_path, "content": content})
|
|
535
|
-
except ToolError as exc:
|
|
536
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
537
|
-
answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
|
|
538
|
-
created_files = [{
|
|
539
|
-
"path": result.get("path") or target_path,
|
|
540
|
-
"filename": Path(result.get("path") or target_path).name,
|
|
541
|
-
"bytes": result.get("bytes", 0),
|
|
542
|
-
"action": "write_file",
|
|
543
|
-
}]
|
|
544
|
-
notify_chat_message("user", req.message, req.source)
|
|
545
|
-
notify_chat_message("assistant", answer, req.source)
|
|
546
|
-
payload = {
|
|
547
|
-
"status": "ok",
|
|
548
|
-
"response": answer,
|
|
549
|
-
"workspace": str(AGENT_ROOT),
|
|
550
|
-
"steps": [{
|
|
551
|
-
"state": AgentState.EXECUTING.value,
|
|
552
|
-
"action": "write_file",
|
|
553
|
-
"args": {"path": target_path},
|
|
554
|
-
"result": result,
|
|
555
|
-
}],
|
|
556
|
-
"state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
|
|
557
|
-
"final_state": AgentState.DONE.value,
|
|
558
|
-
"created_files": created_files,
|
|
559
|
-
"routed_to_agent": True,
|
|
560
|
-
"action_route": "direct_write_file",
|
|
561
|
-
}
|
|
562
|
-
if req.stream:
|
|
563
|
-
async def _stream_file_result():
|
|
564
|
-
yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
|
|
565
|
-
yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
|
|
566
|
-
yield "data: [DONE]\n\n"
|
|
567
|
-
return StreamingResponse(
|
|
568
|
-
_stream_file_result(),
|
|
569
|
-
media_type="text/event-stream",
|
|
570
|
-
headers={"X-Model": router.current_model_id or "tool", "X-Routed-To": "agent"},
|
|
571
|
-
)
|
|
572
|
-
return JSONResponse(content=payload)
|
|
573
|
-
|
|
574
|
-
if not router.current_model_id:
|
|
575
|
-
detail = "No model loaded. Call /models/load first."
|
|
576
|
-
if CONFIG.is_public:
|
|
577
|
-
detail = f"No public model loaded. Set OPENAI_API_KEY and LATTICEAI_PUBLIC_MODEL={PUBLIC_MODEL}, or call /models/load with an OpenAI-compatible model."
|
|
578
|
-
return JSONResponse(
|
|
579
|
-
status_code=400,
|
|
580
|
-
content={
|
|
581
|
-
"error": "no_model_loaded",
|
|
582
|
-
"detail": detail,
|
|
583
|
-
"message": detail,
|
|
584
|
-
"action": "load_model",
|
|
585
|
-
},
|
|
274
|
+
direct_response = await intent_controller.direct_file_action(
|
|
275
|
+
req,
|
|
276
|
+
model_id=selected_model_id,
|
|
586
277
|
)
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
router.switch_model(req.model)
|
|
592
|
-
|
|
278
|
+
if direct_response is not None:
|
|
279
|
+
return direct_response
|
|
280
|
+
if not selected_model_id:
|
|
281
|
+
return intent_controller.no_model_response()
|
|
593
282
|
if is_file_action_request(req.message):
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
user_email=effective_email,
|
|
601
|
-
user_nickname=req.user_nickname,
|
|
602
|
-
workspace_id=workspace_scope_from_request(request),
|
|
283
|
+
return await intent_controller.route_file_to_agent(
|
|
284
|
+
req,
|
|
285
|
+
request,
|
|
286
|
+
effective_email=effective_email,
|
|
287
|
+
workspace_id=workspace_id,
|
|
288
|
+
model_id=selected_model_id,
|
|
603
289
|
)
|
|
604
|
-
result = await agent(agent_req, request)
|
|
605
|
-
answer = str(result.get("response") or "파일 작업을 처리했습니다.")
|
|
606
|
-
notify_chat_message("user", req.message, req.source)
|
|
607
|
-
notify_chat_message("assistant", answer, req.source)
|
|
608
|
-
result["routed_to_agent"] = True
|
|
609
|
-
if req.stream:
|
|
610
|
-
async def _stream_agent_result():
|
|
611
|
-
yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
|
|
612
|
-
yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
|
|
613
|
-
yield "data: [DONE]\n\n"
|
|
614
|
-
return StreamingResponse(
|
|
615
|
-
_stream_agent_result(),
|
|
616
|
-
media_type="text/event-stream",
|
|
617
|
-
headers={"X-Model": router.current_model_id, "X-Routed-To": "agent"},
|
|
618
|
-
)
|
|
619
|
-
return JSONResponse(content=result)
|
|
620
290
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
# v4 Context System: one budgeted, provenance-carrying assembly
|
|
624
|
-
# (workspace memories + hybrid search + garden notes) replaces the
|
|
625
|
-
# ad-hoc vault-scan + LIKE-search concatenation. The trace records
|
|
626
|
-
# why each section is in the prompt.
|
|
291
|
+
language = detect_language(req.message)
|
|
292
|
+
prompt_context = f"[LANGUAGE: {_LANG_HINT[language]}]\n" + (req.context or "")
|
|
627
293
|
context_trace = None
|
|
628
294
|
try:
|
|
629
|
-
if context_assembler is not None:
|
|
630
|
-
assembled = context_assembler.assemble(
|
|
295
|
+
if context.context_assembler is not None:
|
|
296
|
+
assembled = context.context_assembler.assemble(
|
|
631
297
|
req.message,
|
|
632
298
|
user_email=effective_email,
|
|
299
|
+
workspace_id=workspace_id,
|
|
633
300
|
conversation_id=req.conversation_id,
|
|
634
301
|
budget=2000,
|
|
635
302
|
)
|
|
636
303
|
context_trace = assembled.trace()
|
|
637
304
|
if assembled.text:
|
|
638
|
-
|
|
639
|
-
except Exception as
|
|
640
|
-
logging.warning("Context assembly skipped: %s",
|
|
641
|
-
|
|
642
|
-
is_doc_gen = detect_document_intent(req.message)
|
|
643
|
-
doc_gen_context_result = None
|
|
644
|
-
|
|
645
|
-
try:
|
|
646
|
-
if ENABLE_GRAPH and KNOWLEDGE_GRAPH and is_doc_gen:
|
|
647
|
-
# Specialized multi-hop retrieval for document generation.
|
|
648
|
-
doc_gen_context_result = retrieve_context_for_generation(
|
|
649
|
-
KNOWLEDGE_GRAPH, req.message, max_results=10, max_hops=2,
|
|
650
|
-
)
|
|
651
|
-
graph_md = doc_gen_context_result.get("context_markdown", "")
|
|
652
|
-
if graph_md:
|
|
653
|
-
context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
|
|
654
|
-
print("📝 Document generation context retrieved from knowledge graph.")
|
|
655
|
-
except Exception as e:
|
|
656
|
-
logging.warning("Knowledge graph reinforcement skipped: %s", e)
|
|
305
|
+
prompt_context += "\n\n" + assembled.text
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
logging.warning("Context assembly skipped: %s", exc)
|
|
657
308
|
|
|
309
|
+
document_preparation = document_coordinator.prepare(
|
|
310
|
+
req,
|
|
311
|
+
prompt_context,
|
|
312
|
+
workspace_id=workspace_id,
|
|
313
|
+
)
|
|
314
|
+
prompt_context = document_preparation.context
|
|
658
315
|
if req.image_data:
|
|
659
316
|
screenshot_context = extract_screenshot_context(req.image_data)
|
|
660
317
|
if screenshot_context:
|
|
661
|
-
|
|
318
|
+
prompt_context += f"\n\n{screenshot_context}"
|
|
662
319
|
|
|
663
|
-
if
|
|
664
|
-
|
|
665
|
-
|
|
320
|
+
if getattr(config, "auto_read_chat_paths", False):
|
|
321
|
+
file_path_pattern = re.compile(
|
|
322
|
+
r'(?:^|[\s\'"(])((~|/[\w.])[^\s\'")\]]*)',
|
|
323
|
+
re.MULTILINE,
|
|
324
|
+
)
|
|
325
|
+
requested_paths = [
|
|
326
|
+
match.group(1).strip()
|
|
327
|
+
for match in file_path_pattern.finditer(req.message or "")
|
|
328
|
+
]
|
|
666
329
|
if requested_paths:
|
|
667
|
-
append_audit_event(
|
|
330
|
+
context.append_audit_event(
|
|
668
331
|
"auto_file_context_blocked",
|
|
669
332
|
user_email=effective_email,
|
|
670
333
|
path_count=len(requested_paths),
|
|
671
334
|
allow_file_context=req.allow_file_context,
|
|
672
|
-
reason=
|
|
335
|
+
reason=(
|
|
336
|
+
"local file context requires an explicit approved "
|
|
337
|
+
"file/tool flow"
|
|
338
|
+
),
|
|
673
339
|
)
|
|
674
340
|
if req.allow_file_context:
|
|
675
341
|
raise HTTPException(
|
|
676
342
|
status_code=400,
|
|
677
343
|
detail=(
|
|
678
344
|
"Automatic local file reads are disabled in chat. "
|
|
679
|
-
"Attach the file, upload it, or use an approved
|
|
345
|
+
"Attach the file, upload it, or use an approved "
|
|
346
|
+
"local-file tool flow."
|
|
680
347
|
),
|
|
681
348
|
)
|
|
682
349
|
|
|
683
|
-
trace_seed =
|
|
350
|
+
trace_seed = chat_service.build_graph_trace(
|
|
684
351
|
req.message,
|
|
685
|
-
|
|
686
|
-
context
|
|
352
|
+
context.knowledge_graph
|
|
353
|
+
if (context.enable_graph and context.knowledge_graph)
|
|
354
|
+
else None,
|
|
355
|
+
prompt_context,
|
|
356
|
+
allowed_workspaces={workspace_id} if workspace_id else None,
|
|
687
357
|
)
|
|
688
358
|
if context_trace is not None and isinstance(trace_seed, dict):
|
|
689
|
-
# Persisted with the answer trace: 'why is this in my context?'
|
|
690
|
-
# is answerable from the stored record (UI surface lands in T9b).
|
|
691
359
|
trace_seed["context_assembly"] = context_trace
|
|
692
360
|
|
|
693
|
-
history_message =
|
|
694
|
-
|
|
361
|
+
history_message = (
|
|
362
|
+
f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
363
|
+
)
|
|
364
|
+
await chat_service.persist_entry(
|
|
365
|
+
"user",
|
|
366
|
+
history_message,
|
|
367
|
+
history_meta=history_meta,
|
|
368
|
+
history_user=history_user,
|
|
369
|
+
)
|
|
695
370
|
notify_chat_message("user", req.message, req.source)
|
|
696
371
|
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
if req.stream:
|
|
709
|
-
async def _stream_doc_gen():
|
|
710
|
-
collected = []
|
|
711
|
-
async for chunk in router.stream_generate_document(
|
|
712
|
-
req.message, system_prompt,
|
|
713
|
-
max_tokens=req.max_tokens or 8192,
|
|
714
|
-
temperature=req.temperature or 0.3,
|
|
715
|
-
):
|
|
716
|
-
collected.append(chunk)
|
|
717
|
-
yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n"
|
|
718
|
-
full_text = "".join(collected)
|
|
719
|
-
if footnote:
|
|
720
|
-
yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
|
|
721
|
-
full_text += footnote
|
|
722
|
-
session.update(graph_md, full_text, req.conversation_id)
|
|
723
|
-
save_to_history("assistant", full_text, **history_meta, **history_user)
|
|
724
|
-
trace_record = CHAT_SERVICE.record_trace(
|
|
725
|
-
question=req.message,
|
|
726
|
-
response=full_text,
|
|
727
|
-
conversation_id=req.conversation_id,
|
|
728
|
-
user_email=effective_email,
|
|
729
|
-
trace=trace_seed,
|
|
730
|
-
)
|
|
731
|
-
notify_chat_message("assistant", full_text, req.source)
|
|
732
|
-
yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
|
|
733
|
-
yield "data: [DONE]\n\n"
|
|
734
|
-
return StreamingResponse(
|
|
735
|
-
_stream_doc_gen(),
|
|
736
|
-
media_type="text/event-stream",
|
|
737
|
-
headers={"X-Model": router.current_model_id, "X-Doc-Gen": "true"},
|
|
738
|
-
)
|
|
739
|
-
else:
|
|
740
|
-
result = await router.generate_document(
|
|
741
|
-
req.message, system_prompt,
|
|
742
|
-
max_tokens=req.max_tokens or 8192,
|
|
743
|
-
temperature=req.temperature or 0.3,
|
|
744
|
-
)
|
|
745
|
-
if footnote:
|
|
746
|
-
result += footnote
|
|
747
|
-
session.update(graph_md, result, req.conversation_id)
|
|
748
|
-
save_to_history("assistant", str(result), **history_meta, **history_user)
|
|
749
|
-
trace_record = CHAT_SERVICE.record_trace(
|
|
750
|
-
question=req.message,
|
|
751
|
-
response=str(result),
|
|
752
|
-
conversation_id=req.conversation_id,
|
|
753
|
-
user_email=effective_email,
|
|
754
|
-
trace=trace_seed,
|
|
755
|
-
)
|
|
756
|
-
notify_chat_message("assistant", str(result), req.source)
|
|
757
|
-
return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
|
|
372
|
+
document_response = await document_coordinator.response(
|
|
373
|
+
req,
|
|
374
|
+
document_preparation,
|
|
375
|
+
model_id=selected_model_id,
|
|
376
|
+
effective_email=effective_email,
|
|
377
|
+
workspace_id=workspace_id,
|
|
378
|
+
history_meta=history_meta,
|
|
379
|
+
trace_seed=trace_seed,
|
|
380
|
+
)
|
|
381
|
+
if document_response is not None:
|
|
382
|
+
return document_response
|
|
758
383
|
|
|
759
384
|
if req.stream:
|
|
760
|
-
recent_context = recent_chat_context(
|
|
761
|
-
|
|
385
|
+
recent_context = recent_chat_context(
|
|
386
|
+
user_email=effective_email,
|
|
387
|
+
conversation_id=req.conversation_id,
|
|
388
|
+
workspace_id=workspace_id,
|
|
389
|
+
)
|
|
390
|
+
stream_context = prompt_context
|
|
762
391
|
if recent_context:
|
|
763
|
-
stream_context =
|
|
392
|
+
stream_context = (
|
|
393
|
+
f"[RECENT CONVERSATION]\n{recent_context}\n\n{prompt_context}"
|
|
394
|
+
).strip()
|
|
764
395
|
return StreamingResponse(
|
|
765
|
-
|
|
396
|
+
stream_chat(
|
|
766
397
|
req,
|
|
767
398
|
stream_context,
|
|
768
399
|
req.image_data,
|
|
400
|
+
router=model_router,
|
|
401
|
+
chat_service=chat_service,
|
|
402
|
+
knowledge_graph=context.knowledge_graph,
|
|
403
|
+
enable_graph=context.enable_graph,
|
|
404
|
+
notify=notify_chat_message,
|
|
769
405
|
trace_seed=trace_seed,
|
|
770
406
|
effective_email=effective_email,
|
|
771
407
|
history_meta=history_meta,
|
|
408
|
+
model_id=selected_model_id,
|
|
409
|
+
workspace_id=workspace_id,
|
|
772
410
|
),
|
|
773
411
|
media_type="text/event-stream",
|
|
774
|
-
headers={"X-Model":
|
|
412
|
+
headers={"X-Model": selected_model_id},
|
|
775
413
|
)
|
|
776
|
-
else:
|
|
777
|
-
if req.image_data:
|
|
778
|
-
recent_context = recent_chat_context(
|
|
779
|
-
limit=6,
|
|
780
|
-
include_image_missing_replies=False,
|
|
781
|
-
user_email=effective_email,
|
|
782
|
-
conversation_id=req.conversation_id,
|
|
783
|
-
)
|
|
784
|
-
full_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip() if recent_context else context
|
|
785
|
-
else:
|
|
786
|
-
history_context = recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
|
|
787
|
-
full_context = f"{history_context}\n{context}" if context else history_context
|
|
788
|
-
|
|
789
|
-
result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
|
|
790
414
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
415
|
+
if req.image_data:
|
|
416
|
+
recent_context = recent_chat_context(
|
|
417
|
+
limit=6,
|
|
418
|
+
include_image_missing_replies=False,
|
|
419
|
+
user_email=effective_email,
|
|
795
420
|
conversation_id=req.conversation_id,
|
|
421
|
+
workspace_id=workspace_id,
|
|
422
|
+
)
|
|
423
|
+
full_context = (
|
|
424
|
+
f"[RECENT CONVERSATION]\n{recent_context}\n\n{prompt_context}".strip()
|
|
425
|
+
if recent_context
|
|
426
|
+
else prompt_context
|
|
427
|
+
)
|
|
428
|
+
else:
|
|
429
|
+
history_context = recent_chat_context(
|
|
796
430
|
user_email=effective_email,
|
|
797
|
-
|
|
431
|
+
conversation_id=req.conversation_id,
|
|
432
|
+
workspace_id=workspace_id,
|
|
798
433
|
)
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
async def fetch_history_conversations(request: Request):
|
|
812
|
-
"""저장된 히스토리를 대화 단위로 묶어 반환합니다."""
|
|
813
|
-
current_user = require_user(request)
|
|
814
|
-
return group_history_conversations(get_history(**history_scope_for_user(current_user)))
|
|
815
|
-
|
|
816
|
-
@api_router.get("/history/conversations/{conversation_id:path}")
|
|
817
|
-
async def fetch_history_conversation(conversation_id: str, request: Request):
|
|
818
|
-
"""선택한 대화의 메시지를 반환합니다."""
|
|
819
|
-
current_user = require_user(request)
|
|
820
|
-
messages = get_conversation_messages(conversation_id, **history_scope_for_user(current_user))
|
|
821
|
-
if not messages:
|
|
822
|
-
raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
|
|
823
|
-
return {"id": conversation_id, "messages": messages}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
@api_router.delete("/history/conversations/{conversation_id:path}")
|
|
827
|
-
async def delete_history_conversation(conversation_id: str, request: Request):
|
|
828
|
-
"""선택한 대화방의 메시지만 삭제합니다."""
|
|
829
|
-
email = require_user(request)
|
|
830
|
-
result = clear_conversation(
|
|
831
|
-
conversation_id,
|
|
832
|
-
request.query_params.get("started_at"),
|
|
833
|
-
**history_scope_for_user(email),
|
|
834
|
-
)
|
|
835
|
-
append_audit_event(
|
|
836
|
-
"conversation_delete",
|
|
837
|
-
user_email=email,
|
|
838
|
-
conversation_id=conversation_id,
|
|
839
|
-
started_at=request.query_params.get("started_at"),
|
|
840
|
-
removed=result.get("removed", 0),
|
|
841
|
-
kept=result.get("kept", 0),
|
|
842
|
-
)
|
|
843
|
-
return result
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
@api_router.delete("/history")
|
|
847
|
-
async def delete_history(request: Request, keep_last: int = 0):
|
|
848
|
-
email = require_user(request)
|
|
849
|
-
result = clear_history(keep_last, **history_scope_for_user(email))
|
|
850
|
-
append_audit_event(
|
|
851
|
-
"history_delete",
|
|
852
|
-
user_email=email,
|
|
853
|
-
keep_last=keep_last,
|
|
854
|
-
removed=result.get("removed", 0),
|
|
855
|
-
kept=result.get("kept", 0),
|
|
434
|
+
full_context = (
|
|
435
|
+
f"{history_context}\n{prompt_context}"
|
|
436
|
+
if prompt_context
|
|
437
|
+
else history_context
|
|
438
|
+
)
|
|
439
|
+
result = await model_router.generate_as(
|
|
440
|
+
selected_model_id,
|
|
441
|
+
req.message,
|
|
442
|
+
full_context,
|
|
443
|
+
req.max_tokens,
|
|
444
|
+
req.temperature,
|
|
445
|
+
req.image_data,
|
|
856
446
|
)
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
@api_router.get("/history/search")
|
|
860
|
-
async def search_history(q: str, request: Request):
|
|
861
|
-
"""키워드로 채팅 히스토리를 검색합니다."""
|
|
862
|
-
current_user = require_user(request)
|
|
863
|
-
if not q or not q.strip():
|
|
864
|
-
return {"results": [], "query": q}
|
|
865
|
-
q_lower = q.strip().lower()
|
|
866
|
-
history = get_history(**history_scope_for_user(current_user))
|
|
867
|
-
matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
|
|
868
|
-
grouped: Dict[str, Dict] = {}
|
|
869
|
-
for item in matches:
|
|
870
|
-
cid = item.get("conversation_id") or "legacy"
|
|
871
|
-
if cid not in grouped:
|
|
872
|
-
grouped[cid] = {"conversation_id": cid, "title": conversation_title(item), "messages": []}
|
|
873
|
-
grouped[cid]["messages"].append(item)
|
|
874
|
-
return {"results": list(grouped.values())[-30:], "query": q}
|
|
875
|
-
|
|
876
|
-
async def _stream_chat(
|
|
877
|
-
req: ChatRequest,
|
|
878
|
-
context: str = "",
|
|
879
|
-
image_data: str = None,
|
|
880
|
-
*,
|
|
881
|
-
trace_seed: Optional[Dict] = None,
|
|
882
|
-
effective_email: Optional[str] = None,
|
|
883
|
-
history_meta: Optional[Dict] = None,
|
|
884
|
-
) -> AsyncIterator[str]:
|
|
885
|
-
full_response = ""
|
|
886
|
-
async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
|
|
887
|
-
clean_chunk = chunk
|
|
888
|
-
if hasattr(chunk, "text"):
|
|
889
|
-
clean_chunk = chunk.text
|
|
890
|
-
elif isinstance(chunk, str) and "text='" in chunk:
|
|
891
|
-
try:
|
|
892
|
-
clean_chunk = chunk.split("text='")[1].split("', token=")[0].replace('\\n', '\n').replace('\\\\n', '\n')
|
|
893
|
-
except Exception:
|
|
894
|
-
pass
|
|
895
|
-
|
|
896
|
-
full_response += str(clean_chunk)
|
|
897
|
-
yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
|
|
898
|
-
history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
|
|
899
|
-
save_to_history("assistant", full_response, **(history_meta or {}), **history_user)
|
|
900
|
-
trace_record = CHAT_SERVICE.record_trace(
|
|
447
|
+
response_text = str(result)
|
|
448
|
+
trace_record = await chat_service.persist_answer(
|
|
901
449
|
question=req.message,
|
|
902
|
-
response=
|
|
450
|
+
response=response_text,
|
|
903
451
|
conversation_id=req.conversation_id,
|
|
904
|
-
user_email=effective_email
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
452
|
+
user_email=effective_email,
|
|
453
|
+
user_nickname=req.user_nickname,
|
|
454
|
+
source=req.source,
|
|
455
|
+
trace=trace_seed,
|
|
456
|
+
workspace_id=workspace_id,
|
|
457
|
+
history_meta=history_meta,
|
|
458
|
+
notify=notify_chat_message,
|
|
910
459
|
)
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
async def agent_eval(req: AgentEvalRequest, request: Request):
|
|
917
|
-
"""Run a skill's eval cases from schema.json and return pass/fail per case."""
|
|
918
|
-
require_user(request)
|
|
919
|
-
skill_dir = BASE_DIR / "skills" / req.skill
|
|
920
|
-
schema_path = skill_dir / "schema.json"
|
|
921
|
-
if not schema_path.exists():
|
|
922
|
-
raise HTTPException(404, detail=f"Skill '{req.skill}' not found or missing schema.json")
|
|
923
|
-
|
|
924
|
-
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
925
|
-
eval_cases = schema.get("evals", [])
|
|
926
|
-
if req.case_id:
|
|
927
|
-
eval_cases = [c for c in eval_cases if c.get("id") == req.case_id]
|
|
928
|
-
if not eval_cases:
|
|
929
|
-
return {"skill": req.skill, "total": 0, "passed": 0, "failed": 0, "results": [],
|
|
930
|
-
"message": "No eval cases defined in schema.json"}
|
|
931
|
-
|
|
932
|
-
action_name = schema.get("action", req.skill)
|
|
933
|
-
results = []
|
|
934
|
-
for case in eval_cases:
|
|
935
|
-
case_id = case.get("id", "?")
|
|
936
|
-
try:
|
|
937
|
-
case_input = case.get("input", {})
|
|
938
|
-
result = dispatch_tool(hooks, action_name, case_input,
|
|
939
|
-
lambda: execute_tool(action_name, case_input), source="eval")
|
|
940
|
-
criteria = case.get("pass_criteria", "")
|
|
941
|
-
if "success == true" in criteria:
|
|
942
|
-
passed = result.get("success") is True
|
|
943
|
-
elif "success == false" in criteria:
|
|
944
|
-
passed = result.get("success") is False
|
|
945
|
-
else:
|
|
946
|
-
passed = True # manual review required
|
|
947
|
-
results.append({"id": case_id, "description": case.get("description", ""),
|
|
948
|
-
"passed": passed, "result": result, "pass_criteria": criteria})
|
|
949
|
-
except Exception as exc:
|
|
950
|
-
results.append({"id": case_id, "description": case.get("description", ""),
|
|
951
|
-
"passed": False, "error": str(exc),
|
|
952
|
-
"pass_criteria": case.get("pass_criteria", "")})
|
|
953
|
-
|
|
954
|
-
n_passed = sum(1 for r in results if r.get("passed") is True)
|
|
955
|
-
return {
|
|
956
|
-
"skill": req.skill, "action": action_name,
|
|
957
|
-
"total": len(results), "passed": n_passed, "failed": len(results) - n_passed,
|
|
958
|
-
"results": results,
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
@api_router.post("/agent")
|
|
963
|
-
async def agent(req: AgentRequest, request: Request):
|
|
964
|
-
"""Natural-language local agent.
|
|
965
|
-
|
|
966
|
-
State machine:
|
|
967
|
-
IDLE → PLANNING → WAITING_APPROVAL → EXECUTING → VERIFYING
|
|
968
|
-
↓ ↓
|
|
969
|
-
FAILED DONE | EXECUTING(retry) | ROLLBACK
|
|
970
|
-
↓
|
|
971
|
-
FAILED
|
|
972
|
-
"""
|
|
973
|
-
current_user = require_user(request)
|
|
974
|
-
enforce_rate_limit(current_user, "agent")
|
|
975
|
-
req.workspace_id = req.workspace_id or workspace_scope_from_request(request)
|
|
976
|
-
if not router.current_model_id:
|
|
977
|
-
raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
|
|
978
|
-
|
|
979
|
-
ensure_agent_root()
|
|
980
|
-
lang = detect_language(req.message)
|
|
981
|
-
lang_hint = _LANG_HINT[lang]
|
|
982
|
-
max_steps = max(1, min(req.max_steps, 50))
|
|
983
|
-
max_retry = 3
|
|
984
|
-
|
|
985
|
-
ctx = AgentRunContext()
|
|
986
|
-
ctx.executing_model = req.executing_model
|
|
987
|
-
ctx.reviewing_model = req.reviewing_model
|
|
988
|
-
|
|
989
|
-
# PLANNING phase
|
|
990
|
-
ctx.state = AgentState.PLANNING
|
|
991
|
-
ctx.state_history.append(ctx.state.value)
|
|
992
|
-
await _AGENT_RUNTIME.plan(ctx, req, lang_hint, current_user, model_id=req.planning_model)
|
|
993
|
-
|
|
994
|
-
# Human-in-the-loop: pause after planning, return plan to UI
|
|
995
|
-
if req.human_in_loop:
|
|
996
|
-
context_id = secrets.token_urlsafe(16)
|
|
997
|
-
with _pending_agents_lock:
|
|
998
|
-
_pending_agents[context_id] = (ctx, req, lang_hint, current_user)
|
|
999
|
-
return {
|
|
1000
|
-
"status": "waiting_approval",
|
|
1001
|
-
"context_id": context_id,
|
|
1002
|
-
"plan": ctx.plan,
|
|
1003
|
-
"steps": ctx.transcript,
|
|
1004
|
-
"state_history": ctx.state_history,
|
|
1005
|
-
"planning_model": req.planning_model or router.current_model_id,
|
|
1006
|
-
"executing_model": req.executing_model or router.current_model_id,
|
|
1007
|
-
"reviewing_model": req.reviewing_model or router.current_model_id,
|
|
460
|
+
return JSONResponse(
|
|
461
|
+
content={
|
|
462
|
+
"response": response_text,
|
|
463
|
+
"trace_id": trace_record["id"],
|
|
464
|
+
"trace": trace_record,
|
|
1008
465
|
}
|
|
466
|
+
)
|
|
1009
467
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
save_to_history("assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
|
|
1026
|
-
try:
|
|
1027
|
-
WORKSPACE_OS.record_agent_run(
|
|
1028
|
-
agent_id="agent:executor",
|
|
1029
|
-
status="ok" if ctx.state == AgentState.DONE else "failed",
|
|
1030
|
-
input_text=req.message,
|
|
1031
|
-
output_text=message,
|
|
1032
|
-
user_email=current_user or None,
|
|
1033
|
-
timeline=ctx.transcript,
|
|
1034
|
-
relationships=["agent:planner", "agent:reviewer"],
|
|
1035
|
-
graph=workspace_graph(),
|
|
1036
|
-
)
|
|
1037
|
-
except Exception as exc:
|
|
1038
|
-
logging.warning("workspace agent run record failed: %s", exc)
|
|
1039
|
-
created_files = collect_created_files(ctx.transcript)
|
|
1040
|
-
return {
|
|
1041
|
-
"status": "ok" if ctx.state == AgentState.DONE else "failed",
|
|
1042
|
-
"response": message,
|
|
1043
|
-
"workspace": str(AGENT_ROOT),
|
|
1044
|
-
"steps": ctx.transcript,
|
|
1045
|
-
"state_history": ctx.state_history,
|
|
1046
|
-
"final_state": ctx.state.value,
|
|
1047
|
-
"created_files": created_files,
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
@api_router.post("/agent/resume")
|
|
1052
|
-
async def agent_resume(req: AgentResumeRequest, request: Request):
|
|
1053
|
-
"""Resume a paused agent after human approval of the plan."""
|
|
1054
|
-
current_user = require_user(request)
|
|
1055
|
-
|
|
1056
|
-
with _pending_agents_lock:
|
|
1057
|
-
entry = _pending_agents.pop(req.context_id, None)
|
|
1058
|
-
if not entry:
|
|
1059
|
-
raise HTTPException(status_code=404, detail="Agent context not found or expired. Start a new request.")
|
|
1060
|
-
|
|
1061
|
-
ctx, orig_req, lang_hint, _orig_user = entry
|
|
1062
|
-
|
|
1063
|
-
if not req.approved:
|
|
1064
|
-
return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
|
|
1065
|
-
|
|
1066
|
-
if req.modified_plan:
|
|
1067
|
-
ctx.plan = req.modified_plan
|
|
1068
|
-
ctx.transcript[-1].update(ctx.plan) # keep transcript in sync
|
|
1069
|
-
|
|
1070
|
-
# Apply model overrides from resume request (takes priority over original request)
|
|
1071
|
-
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
1072
|
-
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
1073
|
-
|
|
1074
|
-
_AGENT_RUNTIME.approve(ctx, current_user)
|
|
1075
|
-
|
|
1076
|
-
max_steps = max(1, min(orig_req.max_steps, 50))
|
|
1077
|
-
max_retry = 3
|
|
1078
|
-
return await _agent_finish(ctx, orig_req, lang_hint, current_user, max_steps, max_retry)
|
|
1079
|
-
|
|
468
|
+
register_history_routes(
|
|
469
|
+
api_router,
|
|
470
|
+
HistoryRouteDependencies(
|
|
471
|
+
chat_service=chat_service,
|
|
472
|
+
require_user=require_user,
|
|
473
|
+
scope_for_user=history_scope_for_user,
|
|
474
|
+
group_conversations=context.group_history_conversations,
|
|
475
|
+
get_conversation_messages=context.get_conversation_messages,
|
|
476
|
+
conversation_title=context.conversation_title,
|
|
477
|
+
clear_conversation=context.clear_conversation,
|
|
478
|
+
clear_history=context.clear_history,
|
|
479
|
+
append_audit_event=context.append_audit_event,
|
|
480
|
+
),
|
|
481
|
+
)
|
|
482
|
+
agent_controller.register_routes(api_router)
|
|
1080
483
|
return api_router
|