ltcai 8.8.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -37
- package/auto_setup.py +84 -70
- package/docs/CHANGELOG.md +113 -237
- package/docs/CODE_REVIEW_2026-07-06.md +764 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +3 -3
- package/docs/DEVELOPMENT.md +10 -10
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +5 -1
- package/docs/WHY_LATTICE.md +4 -3
- package/docs/architecture.md +4 -0
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -9
- package/lattice_brain/conversations.py +156 -21
- package/lattice_brain/embeddings.py +38 -2
- package/lattice_brain/graph/_kg_common.py +39 -496
- package/lattice_brain/graph/_kg_constants.py +243 -0
- package/lattice_brain/graph/_kg_fsutil.py +297 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +972 -0
- package/lattice_brain/graph/json_utils.py +25 -0
- package/lattice_brain/graph/retrieval.py +66 -597
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -0
- package/lattice_brain/graph/runtime.py +16 -0
- package/lattice_brain/graph/store.py +6 -0
- package/lattice_brain/ingestion.py +68 -0
- package/lattice_brain/portability.py +1 -9
- package/lattice_brain/quality.py +98 -4
- package/lattice_brain/runtime/agent_runtime.py +166 -0
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/lattice_brain/utils.py +28 -0
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +368 -418
- package/latticeai/api/chat_helpers.py +227 -0
- package/latticeai/api/computer_use.py +149 -31
- package/latticeai/api/marketplace.py +11 -0
- package/latticeai/api/mcp.py +3 -2
- package/latticeai/api/models.py +4 -1
- package/latticeai/api/permissions.py +72 -33
- package/latticeai/api/setup.py +17 -2
- package/latticeai/api/tools.py +105 -62
- package/latticeai/app_factory.py +101 -296
- package/latticeai/core/agent.py +25 -7
- package/latticeai/core/io_utils.py +37 -0
- package/latticeai/core/legacy_compatibility.py +1 -1
- 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/sessions.py +11 -3
- package/latticeai/core/tool_registry.py +15 -4
- package/latticeai/core/users.py +4 -9
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/core/workspace_os_utils.py +3 -17
- package/latticeai/integrations/telegram_bot.py +7 -2
- package/latticeai/models/model_providers.py +111 -0
- package/latticeai/models/router.py +58 -136
- package/latticeai/runtime/audit_runtime.py +27 -16
- package/latticeai/runtime/automation_runtime.py +9 -0
- package/latticeai/runtime/bootstrap.py +1 -1
- package/latticeai/runtime/history_runtime.py +163 -0
- package/latticeai/runtime/namespace_runtime.py +173 -0
- package/latticeai/runtime/network_config_runtime.py +56 -0
- package/latticeai/runtime/sso_config_runtime.py +128 -0
- package/latticeai/runtime/user_key_runtime.py +106 -0
- package/latticeai/services/app_context.py +1 -0
- package/latticeai/services/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/model_engines.py +79 -12
- package/latticeai/services/model_runtime.py +24 -4
- package/latticeai/services/platform_runtime.py +9 -1
- package/latticeai/services/process_audit.py +208 -0
- package/latticeai/services/product_readiness.py +11 -11
- package/latticeai/services/review_queue.py +64 -11
- package/latticeai/services/run_executor.py +21 -0
- package/latticeai/services/search_service.py +106 -30
- package/latticeai/services/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +66 -0
- package/latticeai/services/workspace_service.py +15 -0
- package/package.json +1 -1
- package/scripts/check_i18n_literals.mjs +20 -8
- package/scripts/i18n_literal_allowlist.json +34 -0
- package/scripts/lint_frontend.mjs +6 -2
- package/setup_wizard.py +196 -74
- 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-C7K9wsO9.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-B3V4_5Xp.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-Cgj-EF50.js → Library-bFMtyni3.js} +1 -1
- package/static/app/assets/System-K6krGCqn.js +1 -0
- package/static/app/assets/index-C4R3ws30.js +17 -0
- package/static/app/assets/index-ChSeOB02.css +2 -0
- package/static/app/assets/primitives-sQU3it5I.js +1 -0
- package/static/app/assets/{textarea-CVQkN2Tk.js → textarea-DK3Fd_lR.js} +1 -1
- package/static/app/index.html +2 -2
- package/static/css/tokens.css +4 -2
- package/static/sw.js +1 -1
- package/tools/local_files.py +6 -0
- package/latticeai/runtime/sso_runtime.py +0 -52
- package/static/app/assets/Brain-I1OSzxJu.js +0 -321
- package/static/app/assets/System-D1Lkei3I.js +0 -1
- package/static/app/assets/index--P0ksosz.js +0 -17
- package/static/app/assets/index-DCh5AoXt.css +0 -2
- package/static/app/assets/primitives-BLqaKk5g.js +0 -1
package/latticeai/api/chat.py
CHANGED
|
@@ -14,7 +14,7 @@ import subprocess
|
|
|
14
14
|
import tempfile
|
|
15
15
|
import threading
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import AsyncIterator, Dict,
|
|
17
|
+
from typing import Any, AsyncIterator, Dict, Optional
|
|
18
18
|
|
|
19
19
|
from fastapi import APIRouter, HTTPException, Request
|
|
20
20
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
@@ -28,6 +28,25 @@ from lattice_brain.runtime.hooks import dispatch_tool
|
|
|
28
28
|
from latticeai.services.app_context import AppContext
|
|
29
29
|
from latticeai.services.tool_dispatch import build_agent_runtime, collect_created_files
|
|
30
30
|
from tools import AGENT_ROOT, ToolError, ensure_agent_root, execute_tool, knowledge_save, network_status
|
|
31
|
+
# Pure chat helpers (language/intent detection, file-action parsing, recent-context
|
|
32
|
+
# assembly) live in chat_helpers; re-imported here so ``from latticeai.api.chat
|
|
33
|
+
# import <helper>`` keeps resolving. See __all__ below.
|
|
34
|
+
from latticeai.api.chat_helpers import (
|
|
35
|
+
_LANG_HINT,
|
|
36
|
+
build_recent_chat_context,
|
|
37
|
+
detect_language,
|
|
38
|
+
file_action_target,
|
|
39
|
+
format_network_status,
|
|
40
|
+
inline_file_action_content,
|
|
41
|
+
is_clear_command,
|
|
42
|
+
is_current_url_request,
|
|
43
|
+
is_file_action_request,
|
|
44
|
+
is_network_status_request,
|
|
45
|
+
pair_user_history,
|
|
46
|
+
single_text_stream,
|
|
47
|
+
strip_generated_file_content,
|
|
48
|
+
workspace_scope_from_request,
|
|
49
|
+
)
|
|
31
50
|
|
|
32
51
|
class ChatRequest(BaseModel):
|
|
33
52
|
message: str
|
|
@@ -72,195 +91,26 @@ class AgentEvalRequest(BaseModel):
|
|
|
72
91
|
skill: str
|
|
73
92
|
case_id: Optional[str] = None
|
|
74
93
|
|
|
75
|
-
def pair_user_history(history: List[Dict], user_email: str) -> List[Dict]:
|
|
76
|
-
"""Restrict history to one user's exchange.
|
|
77
|
-
|
|
78
|
-
Keeps the user's own messages plus assistant replies that directly follow
|
|
79
|
-
them. A bare role=="assistant" pass would leak every other user's replies
|
|
80
|
-
into this user's prompt context.
|
|
81
|
-
"""
|
|
82
|
-
paired: List[Dict] = []
|
|
83
|
-
include_next_assistant = False
|
|
84
|
-
for item in history:
|
|
85
|
-
if item.get("role") == "assistant":
|
|
86
|
-
if include_next_assistant:
|
|
87
|
-
paired.append(item)
|
|
88
|
-
include_next_assistant = False
|
|
89
|
-
elif item.get("user_email") == user_email:
|
|
90
|
-
paired.append(item)
|
|
91
|
-
include_next_assistant = True
|
|
92
|
-
else:
|
|
93
|
-
include_next_assistant = False
|
|
94
|
-
return paired
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def detect_language(text: str) -> str:
|
|
98
|
-
"""Detect language: 'ko' (Korean) or 'en' (English)."""
|
|
99
|
-
total = max(len(text), 1)
|
|
100
|
-
ko = sum(1 for c in text if '가' <= c <= '힣')
|
|
101
|
-
if ko / total > 0.05:
|
|
102
|
-
return "ko"
|
|
103
|
-
return "en"
|
|
104
|
-
|
|
105
|
-
_LANG_HINT = {
|
|
106
|
-
"ko": "Respond in Korean (한국어로 답변하세요).",
|
|
107
|
-
"en": "Respond in English.",
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
def is_network_status_request(text: str) -> bool:
|
|
111
|
-
"""사용자가 현재 IP/네트워크 정보를 물었는지 감지합니다."""
|
|
112
|
-
t = (text or "").lower()
|
|
113
|
-
has_ip = bool(re.search(r"((?<![a-z0-9])ip(?![a-z0-9])|아이피|ip\s*주소|아이피\s*주소|ipconfig|ifconfig|네트워크)", t))
|
|
114
|
-
asks_current = any(word in t for word in ["내", "현재", "지금", "local", "로컬", "주소", "address", "뭐", "알려", "확인", "상태"])
|
|
115
|
-
return has_ip and asks_current
|
|
116
|
-
|
|
117
|
-
def is_current_url_request(text: str) -> bool:
|
|
118
|
-
t = (text or "").lower()
|
|
119
|
-
has_url = any(word in t for word in ["url", "주소", "링크", "address"])
|
|
120
|
-
asks_current = any(word in t for word in ["현재", "지금", "여기", "접속", "페이지", "브라우저", "알려", "뭐"])
|
|
121
|
-
return has_url and asks_current
|
|
122
|
-
|
|
123
|
-
def is_clear_command(text: str) -> bool:
|
|
124
|
-
return (text or "").strip().lower() in {"/clear", "/clear_all"}
|
|
125
|
-
|
|
126
|
-
# Path segments intentionally exclude spaces: allowing spaces let the target
|
|
127
|
-
# match swallow preceding words (e.g. "create a text file report.txt" resolved
|
|
128
|
-
# to the whole phrase as the path). Chat file targets are single tokens.
|
|
129
|
-
_FILE_TARGET_RE = re.compile(
|
|
130
|
-
r"(?<![\w.-])(?:[~./\\]?[\w.@()+-]+[\\/])*[\w.@()+-]+"
|
|
131
|
-
r"\.(?:py|js|jsx|ts|tsx|md|markdown|txt|json|yaml|yml|toml|html|css|csv|xml|pdf|docx|xlsx|pptx|sh|sql)",
|
|
132
|
-
re.IGNORECASE,
|
|
133
|
-
)
|
|
134
94
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
""
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
)):
|
|
155
|
-
return False
|
|
156
|
-
|
|
157
|
-
has_target = bool(_FILE_TARGET_RE.search(raw))
|
|
158
|
-
has_file_word = any(word in lower for word in (
|
|
159
|
-
"file", "파일", "문서", "artifact", "아티팩트", "save as", "저장",
|
|
160
|
-
))
|
|
161
|
-
has_action = any(word in lower for word in (
|
|
162
|
-
"create", "make", "write", "save", "generate", "edit", "update",
|
|
163
|
-
"만들", "생성", "작성", "저장", "수정", "써줘", "만들어줘",
|
|
164
|
-
))
|
|
165
|
-
|
|
166
|
-
if not has_action:
|
|
167
|
-
return False
|
|
168
|
-
if has_target and has_action:
|
|
169
|
-
return True
|
|
170
|
-
return has_file_word and has_action
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def file_action_target(text: str) -> Optional[str]:
|
|
174
|
-
"""Extract the first explicit workspace file target from a request."""
|
|
175
|
-
match = _FILE_TARGET_RE.search(text or "")
|
|
176
|
-
if not match:
|
|
177
|
-
return None
|
|
178
|
-
return match.group(0).strip().strip("`'\".,:;)]}")
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
def inline_file_action_content(text: str) -> Optional[str]:
|
|
182
|
-
"""Extract short user-provided content for deterministic file writes."""
|
|
183
|
-
raw = (text or "").strip()
|
|
184
|
-
# Each pattern requires an explicit binder so ambiguous words like "text"
|
|
185
|
-
# ("create a text file report.txt") or a bare "with" ("report.md with a
|
|
186
|
-
# summary of X") do not get captured as literal file content.
|
|
187
|
-
patterns = [
|
|
188
|
-
r"(?:내용|본문)\s*(?:은|는|이에요|입니다)\s*(.+)$",
|
|
189
|
-
r"(?:내용|본문|content|body|text)\s*[:=]\s*(.+)$",
|
|
190
|
-
r"(?:content|body)\s+(?:is|as)\s+(.+)$",
|
|
191
|
-
r"(?:with the content|with content|containing)\s+(.+)$",
|
|
192
|
-
]
|
|
193
|
-
for pattern in patterns:
|
|
194
|
-
match = re.search(pattern, raw, flags=re.IGNORECASE | re.DOTALL)
|
|
195
|
-
if match:
|
|
196
|
-
content = match.group(1).strip()
|
|
197
|
-
return content.strip("`'\"")
|
|
198
|
-
return None
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
def strip_generated_file_content(text: str) -> str:
|
|
202
|
-
"""Remove common chat wrappers when a model is asked for file content only."""
|
|
203
|
-
content = (text or "").strip()
|
|
204
|
-
fenced = re.search(r"```(?:[\w.+-]+)?\s*(.*?)\s*```", content, flags=re.DOTALL)
|
|
205
|
-
if fenced:
|
|
206
|
-
content = fenced.group(1).strip()
|
|
207
|
-
return content
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def format_network_status(info: Dict) -> str:
|
|
211
|
-
lines = [
|
|
212
|
-
f"내부 IP: {info.get('local_ip') or '확인 안 됨'}",
|
|
213
|
-
f"외부 IP: {info.get('public_ip') or '확인 안 됨'}",
|
|
214
|
-
f"호스트명: {info.get('hostname') or '확인 안 됨'}",
|
|
215
|
-
]
|
|
216
|
-
local_ips = info.get("local_ips") or {}
|
|
217
|
-
if local_ips:
|
|
218
|
-
lines.extend(["", "인터페이스:"])
|
|
219
|
-
lines.extend(f"- {name}: {ip}" for name, ip in local_ips.items())
|
|
220
|
-
note = info.get("note")
|
|
221
|
-
if note:
|
|
222
|
-
lines.extend(["", note])
|
|
223
|
-
return "\n".join(lines)
|
|
224
|
-
|
|
225
|
-
def workspace_scope_from_request(request: Request) -> Optional[str]:
|
|
226
|
-
header = request.headers.get("X-Workspace-Id")
|
|
227
|
-
if header and header.strip():
|
|
228
|
-
return header.strip()
|
|
229
|
-
query = request.query_params.get("workspace_id")
|
|
230
|
-
return query.strip() if query and query.strip() else None
|
|
231
|
-
|
|
232
|
-
async def single_text_stream(text: str, model: str = "system") -> AsyncIterator[str]:
|
|
233
|
-
yield f"data: {json.dumps({'chunk': text, 'model': model}, ensure_ascii=False)}\n\n"
|
|
234
|
-
yield "data: [DONE]\n\n"
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def build_recent_chat_context(
|
|
238
|
-
*,
|
|
239
|
-
get_history,
|
|
240
|
-
limit: int = 10,
|
|
241
|
-
include_image_missing_replies: bool = True,
|
|
242
|
-
user_email: Optional[str] = None,
|
|
243
|
-
conversation_id: Optional[str] = None,
|
|
244
|
-
) -> str:
|
|
245
|
-
history = get_history()
|
|
246
|
-
if conversation_id:
|
|
247
|
-
history = [item for item in history if item.get("conversation_id") == conversation_id]
|
|
248
|
-
if user_email:
|
|
249
|
-
history = pair_user_history(history, user_email)
|
|
250
|
-
history = history[-limit:]
|
|
251
|
-
lines = []
|
|
252
|
-
for item in history:
|
|
253
|
-
role = item.get("role", "user")
|
|
254
|
-
content = item.get("content", "")
|
|
255
|
-
if not include_image_missing_replies and role == "assistant":
|
|
256
|
-
if "이미지" in content and any(word in content for word in ["업로드", "제공", "올려"]):
|
|
257
|
-
continue
|
|
258
|
-
source = item.get("source")
|
|
259
|
-
label = role
|
|
260
|
-
if source:
|
|
261
|
-
label = f"{role} ({source})"
|
|
262
|
-
lines.append(f"{label}: {content}")
|
|
263
|
-
return "\n".join(lines)
|
|
95
|
+
# The chat helpers imported at the top of the module are re-exported so
|
|
96
|
+
# ``from latticeai.api.chat import <helper>`` (tests, app_factory) keeps resolving
|
|
97
|
+
# after the split; __all__ marks them as intentional re-exports.
|
|
98
|
+
__all__ = [
|
|
99
|
+
"create_chat_router",
|
|
100
|
+
"build_recent_chat_context",
|
|
101
|
+
"pair_user_history",
|
|
102
|
+
"detect_language",
|
|
103
|
+
"file_action_target",
|
|
104
|
+
"inline_file_action_content",
|
|
105
|
+
"is_file_action_request",
|
|
106
|
+
"is_network_status_request",
|
|
107
|
+
"is_current_url_request",
|
|
108
|
+
"is_clear_command",
|
|
109
|
+
"format_network_status",
|
|
110
|
+
"strip_generated_file_content",
|
|
111
|
+
"workspace_scope_from_request",
|
|
112
|
+
"single_text_stream",
|
|
113
|
+
]
|
|
264
114
|
|
|
265
115
|
|
|
266
116
|
def create_chat_router(context: AppContext) -> APIRouter:
|
|
@@ -286,6 +136,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
286
136
|
group_history_conversations = context.group_history_conversations
|
|
287
137
|
get_conversation_messages = context.get_conversation_messages
|
|
288
138
|
conversation_title = context.conversation_title
|
|
139
|
+
allowed_workspaces_for = context.allowed_workspaces_for
|
|
289
140
|
|
|
290
141
|
CONFIG = context.config
|
|
291
142
|
CHAT_SERVICE = context.chat_service
|
|
@@ -297,6 +148,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
297
148
|
_doc_gen_sessions: dict = {}
|
|
298
149
|
_pending_agents: dict[str, tuple] = {}
|
|
299
150
|
_pending_agents_lock = threading.Lock()
|
|
151
|
+
_background_tasks: set[asyncio.Task] = set()
|
|
300
152
|
|
|
301
153
|
on_chat_message = context.on_chat_message
|
|
302
154
|
|
|
@@ -314,6 +166,244 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
314
166
|
except Exception as exc:
|
|
315
167
|
logging.warning("chat message bridge failed: %s", exc)
|
|
316
168
|
|
|
169
|
+
def schedule_background_task(coro) -> None:
|
|
170
|
+
task = asyncio.create_task(coro)
|
|
171
|
+
_background_tasks.add(task)
|
|
172
|
+
|
|
173
|
+
def _finish(done: asyncio.Task) -> None:
|
|
174
|
+
_background_tasks.discard(done)
|
|
175
|
+
try:
|
|
176
|
+
done.result()
|
|
177
|
+
except asyncio.CancelledError:
|
|
178
|
+
pass
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
logging.warning("background chat task failed: %s", exc)
|
|
181
|
+
|
|
182
|
+
task.add_done_callback(_finish)
|
|
183
|
+
|
|
184
|
+
async def save_history_entry(role: str, content: str, history_meta: Dict, history_user: Dict) -> None:
|
|
185
|
+
await asyncio.to_thread(save_to_history, role, content, **history_meta, **history_user)
|
|
186
|
+
|
|
187
|
+
async def persist_chat_exchange(
|
|
188
|
+
req: ChatRequest,
|
|
189
|
+
answer: str,
|
|
190
|
+
*,
|
|
191
|
+
history_meta: Dict,
|
|
192
|
+
history_user: Dict,
|
|
193
|
+
user_message: Optional[str] = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
message = user_message if user_message is not None else req.message
|
|
196
|
+
await save_history_entry("user", message, history_meta, history_user)
|
|
197
|
+
await save_history_entry("assistant", answer, history_meta, history_user)
|
|
198
|
+
notify_chat_message("user", req.message, req.source)
|
|
199
|
+
notify_chat_message("assistant", answer, req.source)
|
|
200
|
+
|
|
201
|
+
def no_model_response() -> JSONResponse:
|
|
202
|
+
detail = "No model loaded. Call /models/load first."
|
|
203
|
+
if CONFIG.is_public:
|
|
204
|
+
detail = f"No public model loaded. Set OPENAI_API_KEY and LATTICEAI_PUBLIC_MODEL={PUBLIC_MODEL}, or call /models/load with an OpenAI-compatible model."
|
|
205
|
+
return JSONResponse(
|
|
206
|
+
status_code=400,
|
|
207
|
+
content={
|
|
208
|
+
"error": "no_model_loaded",
|
|
209
|
+
"detail": detail,
|
|
210
|
+
"message": detail,
|
|
211
|
+
"action": "load_model",
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def single_answer_response(req: ChatRequest, answer: str, *, model: str) -> JSONResponse | StreamingResponse:
|
|
216
|
+
if req.stream:
|
|
217
|
+
return StreamingResponse(
|
|
218
|
+
single_text_stream(answer),
|
|
219
|
+
media_type="text/event-stream",
|
|
220
|
+
headers={"X-Model": model},
|
|
221
|
+
)
|
|
222
|
+
return JSONResponse(content={"response": answer})
|
|
223
|
+
|
|
224
|
+
def agent_payload_stream(answer: str, payload: Dict[str, Any]) -> AsyncIterator[str]:
|
|
225
|
+
async def _stream() -> AsyncIterator[str]:
|
|
226
|
+
yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
|
|
227
|
+
yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
|
|
228
|
+
yield "data: [DONE]\n\n"
|
|
229
|
+
|
|
230
|
+
return _stream()
|
|
231
|
+
|
|
232
|
+
async def handle_network_status_intent(
|
|
233
|
+
req: ChatRequest,
|
|
234
|
+
*,
|
|
235
|
+
history_meta: Dict,
|
|
236
|
+
history_user: Dict,
|
|
237
|
+
) -> JSONResponse | StreamingResponse:
|
|
238
|
+
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
239
|
+
try:
|
|
240
|
+
answer = format_network_status(network_status())
|
|
241
|
+
except ToolError as exc:
|
|
242
|
+
answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
|
|
243
|
+
await persist_chat_exchange(req, answer, history_meta=history_meta, history_user=history_user, user_message=history_message)
|
|
244
|
+
return single_answer_response(req, answer, model="network_status")
|
|
245
|
+
|
|
246
|
+
async def handle_clear_intent(
|
|
247
|
+
req: ChatRequest,
|
|
248
|
+
*,
|
|
249
|
+
effective_email: Optional[str],
|
|
250
|
+
) -> JSONResponse | StreamingResponse:
|
|
251
|
+
command = req.message.strip().lower()
|
|
252
|
+
clear_scope = "all" if command == "/clear_all" else "conversation"
|
|
253
|
+
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
254
|
+
try:
|
|
255
|
+
KNOWLEDGE_GRAPH.ingest_event(
|
|
256
|
+
"ClearEvent",
|
|
257
|
+
f"{command} requested",
|
|
258
|
+
user_email=effective_email,
|
|
259
|
+
user_nickname=req.user_nickname,
|
|
260
|
+
source=req.source or "web",
|
|
261
|
+
conversation_id=req.conversation_id,
|
|
262
|
+
metadata={"command": command, "scope": clear_scope},
|
|
263
|
+
)
|
|
264
|
+
except Exception as e:
|
|
265
|
+
logging.warning("knowledge graph clear event ingest failed: %s", e)
|
|
266
|
+
if command == "/clear_all":
|
|
267
|
+
result = clear_history(0, **history_scope_for_user(effective_email))
|
|
268
|
+
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
269
|
+
elif req.conversation_id:
|
|
270
|
+
result = clear_conversation(req.conversation_id, **history_scope_for_user(effective_email))
|
|
271
|
+
answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
272
|
+
else:
|
|
273
|
+
result = clear_history(0, **history_scope_for_user(effective_email))
|
|
274
|
+
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
275
|
+
append_audit_event(
|
|
276
|
+
"clear_command",
|
|
277
|
+
user_email=effective_email,
|
|
278
|
+
user_nickname=req.user_nickname,
|
|
279
|
+
source=req.source or "web",
|
|
280
|
+
conversation_id=req.conversation_id,
|
|
281
|
+
command=command,
|
|
282
|
+
scope=clear_scope,
|
|
283
|
+
removed=result.get("removed", 0),
|
|
284
|
+
kept=result.get("kept", 0),
|
|
285
|
+
)
|
|
286
|
+
notify_chat_message("user", req.message, req.source)
|
|
287
|
+
notify_chat_message("assistant", answer, req.source)
|
|
288
|
+
return single_answer_response(req, answer, model="history")
|
|
289
|
+
|
|
290
|
+
async def handle_current_url_intent(
|
|
291
|
+
req: ChatRequest,
|
|
292
|
+
*,
|
|
293
|
+
history_meta: Dict,
|
|
294
|
+
history_user: Dict,
|
|
295
|
+
) -> JSONResponse | StreamingResponse:
|
|
296
|
+
answer = f"현재 페이지 URL: {req.client_url}"
|
|
297
|
+
await persist_chat_exchange(req, answer, history_meta=history_meta, history_user=history_user)
|
|
298
|
+
return single_answer_response(req, answer, model="client_url")
|
|
299
|
+
|
|
300
|
+
async def handle_direct_file_action(req: ChatRequest) -> Optional[JSONResponse | StreamingResponse]:
|
|
301
|
+
target_path = file_action_target(req.message)
|
|
302
|
+
if not target_path:
|
|
303
|
+
return None
|
|
304
|
+
content = inline_file_action_content(req.message)
|
|
305
|
+
if content is None and not router.current_model_id:
|
|
306
|
+
return no_model_response()
|
|
307
|
+
if content is None and router.current_model_id:
|
|
308
|
+
if req.model and req.model != router.current_model_id:
|
|
309
|
+
if req.model not in router.loaded_model_ids:
|
|
310
|
+
raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
|
|
311
|
+
router.switch_model(req.model)
|
|
312
|
+
generation_context = (
|
|
313
|
+
"Create the exact content for the requested file. "
|
|
314
|
+
"Return only the file bytes as plain text. "
|
|
315
|
+
"Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
|
|
316
|
+
f"Target path: {target_path}\n"
|
|
317
|
+
f"User request: {req.message}"
|
|
318
|
+
)
|
|
319
|
+
raw_content = await router.generate_as(
|
|
320
|
+
router.current_model_id,
|
|
321
|
+
message="Return only the requested file content.",
|
|
322
|
+
context=generation_context,
|
|
323
|
+
max_tokens=req.max_tokens,
|
|
324
|
+
temperature=req.temperature,
|
|
325
|
+
)
|
|
326
|
+
content = strip_generated_file_content(str(raw_content))
|
|
327
|
+
if content is None:
|
|
328
|
+
raise HTTPException(status_code=400, detail="File content could not be generated.")
|
|
329
|
+
try:
|
|
330
|
+
result = execute_tool("write_file", {"path": target_path, "content": content})
|
|
331
|
+
except ToolError as exc:
|
|
332
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
333
|
+
answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
|
|
334
|
+
created_files = [{
|
|
335
|
+
"path": result.get("path") or target_path,
|
|
336
|
+
"filename": Path(result.get("path") or target_path).name,
|
|
337
|
+
"bytes": result.get("bytes", 0),
|
|
338
|
+
"action": "write_file",
|
|
339
|
+
}]
|
|
340
|
+
notify_chat_message("user", req.message, req.source)
|
|
341
|
+
notify_chat_message("assistant", answer, req.source)
|
|
342
|
+
payload = {
|
|
343
|
+
"status": "ok",
|
|
344
|
+
"response": answer,
|
|
345
|
+
"workspace": str(AGENT_ROOT),
|
|
346
|
+
"steps": [{
|
|
347
|
+
"state": AgentState.EXECUTING.value,
|
|
348
|
+
"action": "write_file",
|
|
349
|
+
"args": {"path": target_path},
|
|
350
|
+
"result": result,
|
|
351
|
+
}],
|
|
352
|
+
"state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
|
|
353
|
+
"final_state": AgentState.DONE.value,
|
|
354
|
+
"created_files": created_files,
|
|
355
|
+
"routed_to_agent": True,
|
|
356
|
+
"action_route": "direct_write_file",
|
|
357
|
+
}
|
|
358
|
+
if req.stream:
|
|
359
|
+
return StreamingResponse(
|
|
360
|
+
agent_payload_stream(answer, payload),
|
|
361
|
+
media_type="text/event-stream",
|
|
362
|
+
headers={"X-Model": router.current_model_id or "tool", "X-Routed-To": "agent"},
|
|
363
|
+
)
|
|
364
|
+
return JSONResponse(content=payload)
|
|
365
|
+
|
|
366
|
+
async def route_file_action_to_agent(
|
|
367
|
+
req: ChatRequest,
|
|
368
|
+
request: Request,
|
|
369
|
+
*,
|
|
370
|
+
effective_email: Optional[str],
|
|
371
|
+
) -> JSONResponse | StreamingResponse:
|
|
372
|
+
agent_req = AgentRequest(
|
|
373
|
+
message=req.message,
|
|
374
|
+
conversation_id=req.conversation_id,
|
|
375
|
+
source=req.source or "web",
|
|
376
|
+
max_steps=25,
|
|
377
|
+
temperature=min(req.temperature, 0.2),
|
|
378
|
+
user_email=effective_email,
|
|
379
|
+
user_nickname=req.user_nickname,
|
|
380
|
+
workspace_id=workspace_scope_from_request(request),
|
|
381
|
+
)
|
|
382
|
+
result = await agent(agent_req, request)
|
|
383
|
+
answer = str(result.get("response") or "파일 작업을 처리했습니다.")
|
|
384
|
+
notify_chat_message("user", req.message, req.source)
|
|
385
|
+
notify_chat_message("assistant", answer, req.source)
|
|
386
|
+
result["routed_to_agent"] = True
|
|
387
|
+
if req.stream:
|
|
388
|
+
return StreamingResponse(
|
|
389
|
+
agent_payload_stream(answer, result),
|
|
390
|
+
media_type="text/event-stream",
|
|
391
|
+
headers={"X-Model": router.current_model_id, "X-Routed-To": "agent"},
|
|
392
|
+
)
|
|
393
|
+
return JSONResponse(content=result)
|
|
394
|
+
|
|
395
|
+
def history_scope_for_user(user_email: Optional[str]) -> Dict:
|
|
396
|
+
require_auth = bool(getattr(CONFIG, "require_auth", False))
|
|
397
|
+
scoped_user = user_email if require_auth else None
|
|
398
|
+
allowed = None
|
|
399
|
+
if require_auth and scoped_user and allowed_workspaces_for is not None:
|
|
400
|
+
allowed = allowed_workspaces_for(scoped_user)
|
|
401
|
+
return {
|
|
402
|
+
"user_email": scoped_user,
|
|
403
|
+
"allowed_workspaces": allowed,
|
|
404
|
+
"include_legacy_global": not require_auth,
|
|
405
|
+
}
|
|
406
|
+
|
|
317
407
|
def recent_chat_context(
|
|
318
408
|
limit: int = 10,
|
|
319
409
|
include_image_missing_replies: bool = True,
|
|
@@ -402,9 +492,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
402
492
|
current_user = require_user(request)
|
|
403
493
|
enforce_rate_limit(current_user, "chat")
|
|
404
494
|
img_len = len(req.image_data) if req.image_data else 0
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
495
|
+
logging.debug(
|
|
496
|
+
"/chat request: stream=%s image_data_len=%s message_len=%s",
|
|
497
|
+
req.stream,
|
|
498
|
+
img_len,
|
|
499
|
+
len(req.message or ""),
|
|
408
500
|
)
|
|
409
501
|
effective_email = req.user_email or current_user or None
|
|
410
502
|
history_user = get_history_user(effective_email, req.user_nickname)
|
|
@@ -415,162 +507,21 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
415
507
|
}
|
|
416
508
|
|
|
417
509
|
if is_network_status_request(req.message):
|
|
418
|
-
|
|
419
|
-
save_to_history("user", history_message, **history_meta, **history_user)
|
|
420
|
-
try:
|
|
421
|
-
answer = format_network_status(network_status())
|
|
422
|
-
except ToolError as exc:
|
|
423
|
-
answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
|
|
424
|
-
save_to_history("assistant", answer, **history_meta, **history_user)
|
|
425
|
-
notify_chat_message("user", req.message, req.source)
|
|
426
|
-
notify_chat_message("assistant", answer, req.source)
|
|
427
|
-
if req.stream:
|
|
428
|
-
return StreamingResponse(
|
|
429
|
-
single_text_stream(answer),
|
|
430
|
-
media_type="text/event-stream",
|
|
431
|
-
headers={"X-Model": "network_status"},
|
|
432
|
-
)
|
|
433
|
-
return JSONResponse(content={"response": answer})
|
|
510
|
+
return await handle_network_status_intent(req, history_meta=history_meta, history_user=history_user)
|
|
434
511
|
|
|
435
512
|
if is_clear_command(req.message):
|
|
436
|
-
|
|
437
|
-
clear_scope = "all" if command == "/clear_all" else "conversation"
|
|
438
|
-
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
439
|
-
try:
|
|
440
|
-
KNOWLEDGE_GRAPH.ingest_event(
|
|
441
|
-
"ClearEvent",
|
|
442
|
-
f"{command} requested",
|
|
443
|
-
user_email=effective_email,
|
|
444
|
-
user_nickname=req.user_nickname,
|
|
445
|
-
source=req.source or "web",
|
|
446
|
-
conversation_id=req.conversation_id,
|
|
447
|
-
metadata={"command": command, "scope": clear_scope},
|
|
448
|
-
)
|
|
449
|
-
except Exception as e:
|
|
450
|
-
logging.warning("knowledge graph clear event ingest failed: %s", e)
|
|
451
|
-
if command == "/clear_all":
|
|
452
|
-
result = clear_history(0)
|
|
453
|
-
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
454
|
-
else:
|
|
455
|
-
if req.conversation_id:
|
|
456
|
-
result = clear_conversation(req.conversation_id)
|
|
457
|
-
answer = f"현재 대화방 채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
458
|
-
else:
|
|
459
|
-
result = clear_history(0)
|
|
460
|
-
answer = f"채팅창을 정리했습니다. 화면에서 제거 {result.get('removed', 0)}개. 감사 로그와 지식 그래프/RAG 데이터는 유지됩니다."
|
|
461
|
-
append_audit_event(
|
|
462
|
-
"clear_command",
|
|
463
|
-
user_email=effective_email,
|
|
464
|
-
user_nickname=req.user_nickname,
|
|
465
|
-
source=req.source or "web",
|
|
466
|
-
conversation_id=req.conversation_id,
|
|
467
|
-
command=command,
|
|
468
|
-
scope=clear_scope,
|
|
469
|
-
removed=result.get("removed", 0),
|
|
470
|
-
kept=result.get("kept", 0),
|
|
471
|
-
)
|
|
472
|
-
if req.stream:
|
|
473
|
-
return StreamingResponse(
|
|
474
|
-
single_text_stream(answer),
|
|
475
|
-
media_type="text/event-stream",
|
|
476
|
-
headers={"X-Model": "history"},
|
|
477
|
-
)
|
|
478
|
-
return JSONResponse(content={"response": answer})
|
|
513
|
+
return await handle_clear_intent(req, effective_email=effective_email)
|
|
479
514
|
|
|
480
515
|
if is_current_url_request(req.message) and req.client_url:
|
|
481
|
-
|
|
482
|
-
save_to_history("user", req.message, **history_meta, **history_user)
|
|
483
|
-
save_to_history("assistant", answer, **history_meta, **history_user)
|
|
484
|
-
notify_chat_message("user", req.message, req.source)
|
|
485
|
-
notify_chat_message("assistant", answer, req.source)
|
|
486
|
-
if req.stream:
|
|
487
|
-
return StreamingResponse(
|
|
488
|
-
single_text_stream(answer),
|
|
489
|
-
media_type="text/event-stream",
|
|
490
|
-
headers={"X-Model": "client_url"},
|
|
491
|
-
)
|
|
492
|
-
return JSONResponse(content={"response": answer})
|
|
516
|
+
return await handle_current_url_intent(req, history_meta=history_meta, history_user=history_user)
|
|
493
517
|
|
|
494
518
|
if is_file_action_request(req.message):
|
|
495
|
-
|
|
496
|
-
if
|
|
497
|
-
|
|
498
|
-
if content is None and router.current_model_id:
|
|
499
|
-
if req.model and req.model != router.current_model_id:
|
|
500
|
-
if req.model not in router.loaded_model_ids:
|
|
501
|
-
raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
|
|
502
|
-
router.switch_model(req.model)
|
|
503
|
-
generation_context = (
|
|
504
|
-
"Create the exact content for the requested file. "
|
|
505
|
-
"Return only the file bytes as plain text. "
|
|
506
|
-
"Do not wrap the answer in Markdown fences, commentary, or explanations.\n\n"
|
|
507
|
-
f"Target path: {target_path}\n"
|
|
508
|
-
f"User request: {req.message}"
|
|
509
|
-
)
|
|
510
|
-
raw_content = await router.generate_as(
|
|
511
|
-
router.current_model_id,
|
|
512
|
-
message="Return only the requested file content.",
|
|
513
|
-
context=generation_context,
|
|
514
|
-
max_tokens=req.max_tokens,
|
|
515
|
-
temperature=req.temperature,
|
|
516
|
-
)
|
|
517
|
-
content = strip_generated_file_content(str(raw_content))
|
|
518
|
-
if content is None:
|
|
519
|
-
content = ""
|
|
520
|
-
try:
|
|
521
|
-
result = execute_tool("write_file", {"path": target_path, "content": content})
|
|
522
|
-
except ToolError as exc:
|
|
523
|
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
524
|
-
answer = f"{result.get('path') or target_path} 파일을 만들었습니다."
|
|
525
|
-
created_files = [{
|
|
526
|
-
"path": result.get("path") or target_path,
|
|
527
|
-
"filename": Path(result.get("path") or target_path).name,
|
|
528
|
-
"bytes": result.get("bytes", 0),
|
|
529
|
-
"action": "write_file",
|
|
530
|
-
}]
|
|
531
|
-
notify_chat_message("user", req.message, req.source)
|
|
532
|
-
notify_chat_message("assistant", answer, req.source)
|
|
533
|
-
payload = {
|
|
534
|
-
"status": "ok",
|
|
535
|
-
"response": answer,
|
|
536
|
-
"workspace": str(AGENT_ROOT),
|
|
537
|
-
"steps": [{
|
|
538
|
-
"state": AgentState.EXECUTING.value,
|
|
539
|
-
"action": "write_file",
|
|
540
|
-
"args": {"path": target_path},
|
|
541
|
-
"result": result,
|
|
542
|
-
}],
|
|
543
|
-
"state_history": [AgentState.EXECUTING.value, AgentState.DONE.value],
|
|
544
|
-
"final_state": AgentState.DONE.value,
|
|
545
|
-
"created_files": created_files,
|
|
546
|
-
"routed_to_agent": True,
|
|
547
|
-
"action_route": "direct_write_file",
|
|
548
|
-
}
|
|
549
|
-
if req.stream:
|
|
550
|
-
async def _stream_file_result():
|
|
551
|
-
yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
|
|
552
|
-
yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': payload}, ensure_ascii=False)}\n\n"
|
|
553
|
-
yield "data: [DONE]\n\n"
|
|
554
|
-
return StreamingResponse(
|
|
555
|
-
_stream_file_result(),
|
|
556
|
-
media_type="text/event-stream",
|
|
557
|
-
headers={"X-Model": router.current_model_id or "tool", "X-Routed-To": "agent"},
|
|
558
|
-
)
|
|
559
|
-
return JSONResponse(content=payload)
|
|
519
|
+
direct_response = await handle_direct_file_action(req)
|
|
520
|
+
if direct_response is not None:
|
|
521
|
+
return direct_response
|
|
560
522
|
|
|
561
523
|
if not router.current_model_id:
|
|
562
|
-
|
|
563
|
-
if CONFIG.is_public:
|
|
564
|
-
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."
|
|
565
|
-
return JSONResponse(
|
|
566
|
-
status_code=400,
|
|
567
|
-
content={
|
|
568
|
-
"error": "no_model_loaded",
|
|
569
|
-
"detail": detail,
|
|
570
|
-
"message": detail,
|
|
571
|
-
"action": "load_model",
|
|
572
|
-
},
|
|
573
|
-
)
|
|
524
|
+
return no_model_response()
|
|
574
525
|
|
|
575
526
|
if req.model and req.model != router.current_model_id:
|
|
576
527
|
if req.model not in router.loaded_model_ids:
|
|
@@ -578,32 +529,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
578
529
|
router.switch_model(req.model)
|
|
579
530
|
|
|
580
531
|
if is_file_action_request(req.message):
|
|
581
|
-
|
|
582
|
-
message=req.message,
|
|
583
|
-
conversation_id=req.conversation_id,
|
|
584
|
-
source=req.source or "web",
|
|
585
|
-
max_steps=25,
|
|
586
|
-
temperature=min(req.temperature, 0.2),
|
|
587
|
-
user_email=effective_email,
|
|
588
|
-
user_nickname=req.user_nickname,
|
|
589
|
-
workspace_id=workspace_scope_from_request(request),
|
|
590
|
-
)
|
|
591
|
-
result = await agent(agent_req, request)
|
|
592
|
-
answer = str(result.get("response") or "파일 작업을 처리했습니다.")
|
|
593
|
-
notify_chat_message("user", req.message, req.source)
|
|
594
|
-
notify_chat_message("assistant", answer, req.source)
|
|
595
|
-
result["routed_to_agent"] = True
|
|
596
|
-
if req.stream:
|
|
597
|
-
async def _stream_agent_result():
|
|
598
|
-
yield f"data: {json.dumps({'chunk': answer, 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
|
|
599
|
-
yield f"data: {json.dumps({'chunk': '', 'model': router.current_model_id, 'agent': result}, ensure_ascii=False)}\n\n"
|
|
600
|
-
yield "data: [DONE]\n\n"
|
|
601
|
-
return StreamingResponse(
|
|
602
|
-
_stream_agent_result(),
|
|
603
|
-
media_type="text/event-stream",
|
|
604
|
-
headers={"X-Model": router.current_model_id, "X-Routed-To": "agent"},
|
|
605
|
-
)
|
|
606
|
-
return JSONResponse(content=result)
|
|
532
|
+
return await route_file_action_to_agent(req, request, effective_email=effective_email)
|
|
607
533
|
|
|
608
534
|
lang = detect_language(req.message)
|
|
609
535
|
context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
|
|
@@ -638,7 +564,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
638
564
|
graph_md = doc_gen_context_result.get("context_markdown", "")
|
|
639
565
|
if graph_md:
|
|
640
566
|
context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
|
|
641
|
-
|
|
567
|
+
logging.debug("Document generation context retrieved from knowledge graph.")
|
|
642
568
|
except Exception as e:
|
|
643
569
|
logging.warning("Knowledge graph reinforcement skipped: %s", e)
|
|
644
570
|
|
|
@@ -678,7 +604,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
678
604
|
trace_seed["context_assembly"] = context_trace
|
|
679
605
|
|
|
680
606
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
681
|
-
save_to_history
|
|
607
|
+
await asyncio.to_thread(save_to_history, "user", history_message, **history_meta, **history_user)
|
|
682
608
|
notify_chat_message("user", req.message, req.source)
|
|
683
609
|
|
|
684
610
|
if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
@@ -695,19 +621,27 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
695
621
|
if req.stream:
|
|
696
622
|
async def _stream_doc_gen():
|
|
697
623
|
collected = []
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
624
|
+
stream_error = None
|
|
625
|
+
try:
|
|
626
|
+
async for chunk in router.stream_generate_document(
|
|
627
|
+
req.message, system_prompt,
|
|
628
|
+
max_tokens=req.max_tokens or 8192,
|
|
629
|
+
temperature=req.temperature or 0.3,
|
|
630
|
+
):
|
|
631
|
+
collected.append(chunk)
|
|
632
|
+
yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n"
|
|
633
|
+
except Exception as exc:
|
|
634
|
+
stream_error = str(exc)
|
|
635
|
+
logging.warning("document stream failed: %s", exc)
|
|
636
|
+
yield f"data: {json.dumps({'error': stream_error}, ensure_ascii=False)}\n\n"
|
|
705
637
|
full_text = "".join(collected)
|
|
706
638
|
if footnote:
|
|
707
639
|
yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
|
|
708
640
|
full_text += footnote
|
|
641
|
+
if stream_error:
|
|
642
|
+
full_text = f"{full_text}\n\n[stream_error] {stream_error}" if full_text else f"[stream_error] {stream_error}"
|
|
709
643
|
session.update(graph_md, full_text, req.conversation_id)
|
|
710
|
-
save_to_history
|
|
644
|
+
await asyncio.to_thread(save_to_history, "assistant", full_text, **history_meta, **history_user)
|
|
711
645
|
trace_record = CHAT_SERVICE.record_trace(
|
|
712
646
|
question=req.message,
|
|
713
647
|
response=full_text,
|
|
@@ -732,7 +666,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
732
666
|
if footnote:
|
|
733
667
|
result += footnote
|
|
734
668
|
session.update(graph_md, result, req.conversation_id)
|
|
735
|
-
save_to_history
|
|
669
|
+
await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
|
|
736
670
|
trace_record = CHAT_SERVICE.record_trace(
|
|
737
671
|
question=req.message,
|
|
738
672
|
response=str(result),
|
|
@@ -775,7 +709,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
775
709
|
|
|
776
710
|
result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
|
|
777
711
|
|
|
778
|
-
save_to_history
|
|
712
|
+
await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
|
|
779
713
|
trace_record = CHAT_SERVICE.record_trace(
|
|
780
714
|
question=req.message,
|
|
781
715
|
response=str(result),
|
|
@@ -791,20 +725,20 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
791
725
|
@api_router.get("/history")
|
|
792
726
|
async def fetch_history(request: Request):
|
|
793
727
|
"""웹 화면에서 이전 대화를 불러올 수 있도록 히스토리를 반환합니다."""
|
|
794
|
-
require_user(request)
|
|
795
|
-
return get_history()
|
|
728
|
+
current_user = require_user(request)
|
|
729
|
+
return get_history(**history_scope_for_user(current_user))
|
|
796
730
|
|
|
797
731
|
@api_router.get("/history/conversations")
|
|
798
732
|
async def fetch_history_conversations(request: Request):
|
|
799
733
|
"""저장된 히스토리를 대화 단위로 묶어 반환합니다."""
|
|
800
|
-
require_user(request)
|
|
801
|
-
return group_history_conversations()
|
|
734
|
+
current_user = require_user(request)
|
|
735
|
+
return group_history_conversations(get_history(**history_scope_for_user(current_user)))
|
|
802
736
|
|
|
803
737
|
@api_router.get("/history/conversations/{conversation_id:path}")
|
|
804
738
|
async def fetch_history_conversation(conversation_id: str, request: Request):
|
|
805
739
|
"""선택한 대화의 메시지를 반환합니다."""
|
|
806
|
-
require_user(request)
|
|
807
|
-
messages = get_conversation_messages(conversation_id)
|
|
740
|
+
current_user = require_user(request)
|
|
741
|
+
messages = get_conversation_messages(conversation_id, **history_scope_for_user(current_user))
|
|
808
742
|
if not messages:
|
|
809
743
|
raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
|
|
810
744
|
return {"id": conversation_id, "messages": messages}
|
|
@@ -814,7 +748,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
814
748
|
async def delete_history_conversation(conversation_id: str, request: Request):
|
|
815
749
|
"""선택한 대화방의 메시지만 삭제합니다."""
|
|
816
750
|
email = require_user(request)
|
|
817
|
-
result = clear_conversation(
|
|
751
|
+
result = clear_conversation(
|
|
752
|
+
conversation_id,
|
|
753
|
+
request.query_params.get("started_at"),
|
|
754
|
+
**history_scope_for_user(email),
|
|
755
|
+
)
|
|
818
756
|
append_audit_event(
|
|
819
757
|
"conversation_delete",
|
|
820
758
|
user_email=email,
|
|
@@ -829,7 +767,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
829
767
|
@api_router.delete("/history")
|
|
830
768
|
async def delete_history(request: Request, keep_last: int = 0):
|
|
831
769
|
email = require_user(request)
|
|
832
|
-
result = clear_history(keep_last)
|
|
770
|
+
result = clear_history(keep_last, **history_scope_for_user(email))
|
|
833
771
|
append_audit_event(
|
|
834
772
|
"history_delete",
|
|
835
773
|
user_email=email,
|
|
@@ -842,11 +780,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
842
780
|
@api_router.get("/history/search")
|
|
843
781
|
async def search_history(q: str, request: Request):
|
|
844
782
|
"""키워드로 채팅 히스토리를 검색합니다."""
|
|
845
|
-
require_user(request)
|
|
783
|
+
current_user = require_user(request)
|
|
846
784
|
if not q or not q.strip():
|
|
847
785
|
return {"results": [], "query": q}
|
|
848
786
|
q_lower = q.strip().lower()
|
|
849
|
-
history = get_history()
|
|
787
|
+
history = get_history(**history_scope_for_user(current_user))
|
|
850
788
|
matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
|
|
851
789
|
grouped: Dict[str, Dict] = {}
|
|
852
790
|
for item in matches:
|
|
@@ -866,33 +804,45 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
866
804
|
history_meta: Optional[Dict] = None,
|
|
867
805
|
) -> AsyncIterator[str]:
|
|
868
806
|
full_response = ""
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
clean_chunk = chunk.text
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
full_response += str(clean_chunk)
|
|
880
|
-
yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
|
|
807
|
+
stream_error: Optional[str] = None
|
|
808
|
+
try:
|
|
809
|
+
async for chunk in router.stream_generate(req.message, context, req.max_tokens, req.temperature, image_data):
|
|
810
|
+
clean_chunk = chunk.text if hasattr(chunk, "text") else chunk
|
|
811
|
+
full_response += str(clean_chunk)
|
|
812
|
+
yield f"data: {json.dumps({'chunk': clean_chunk, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
|
|
813
|
+
except Exception as exc:
|
|
814
|
+
stream_error = str(exc)
|
|
815
|
+
logging.warning("chat stream failed: %s", exc)
|
|
816
|
+
yield f"data: {json.dumps({'error': stream_error, 'model': router.current_model_id}, ensure_ascii=False)}\n\n"
|
|
881
817
|
history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
818
|
+
persisted_response = full_response
|
|
819
|
+
if stream_error and not persisted_response:
|
|
820
|
+
persisted_response = f"[stream_error] {stream_error}"
|
|
821
|
+
elif stream_error:
|
|
822
|
+
persisted_response = f"{persisted_response}\n\n[stream_error] {stream_error}"
|
|
823
|
+
trace_record = None
|
|
824
|
+
try:
|
|
825
|
+
await asyncio.to_thread(save_to_history, "assistant", persisted_response, **(history_meta or {}), **history_user)
|
|
826
|
+
trace_record = CHAT_SERVICE.record_trace(
|
|
827
|
+
question=req.message,
|
|
828
|
+
response=persisted_response,
|
|
829
|
+
conversation_id=req.conversation_id,
|
|
830
|
+
user_email=effective_email or req.user_email,
|
|
831
|
+
trace=trace_seed or CHAT_SERVICE.build_graph_trace(
|
|
832
|
+
req.message,
|
|
833
|
+
KNOWLEDGE_GRAPH if (ENABLE_GRAPH and KNOWLEDGE_GRAPH) else None,
|
|
834
|
+
context,
|
|
835
|
+
),
|
|
836
|
+
)
|
|
837
|
+
notify_chat_message("assistant", persisted_response, req.source)
|
|
838
|
+
except Exception as exc:
|
|
839
|
+
logging.warning("chat stream persistence failed: %s", exc)
|
|
840
|
+
trailer = {"chunk": "", "model": router.current_model_id}
|
|
841
|
+
if trace_record:
|
|
842
|
+
trailer.update({"trace_id": trace_record["id"], "trace": trace_record})
|
|
843
|
+
if stream_error:
|
|
844
|
+
trailer["error"] = stream_error
|
|
845
|
+
yield f"data: {json.dumps(trailer, ensure_ascii=False)}\n\n"
|
|
896
846
|
yield "data: [DONE]\n\n"
|
|
897
847
|
|
|
898
848
|
@api_router.post("/agent/eval")
|
|
@@ -991,7 +941,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
991
941
|
}
|
|
992
942
|
|
|
993
943
|
# Auto-approve and run to completion (default behaviour)
|
|
994
|
-
_AGENT_RUNTIME.approve(ctx, current_user)
|
|
944
|
+
_AGENT_RUNTIME.approve(ctx, current_user, approved_by_human=True)
|
|
995
945
|
return await _agent_finish(ctx, req, lang_hint, current_user, max_steps, max_retry)
|
|
996
946
|
|
|
997
947
|
|
|
@@ -1001,11 +951,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
1001
951
|
) -> dict:
|
|
1002
952
|
"""HTTP glue: drive the runtime to a terminal state, persist, shape the response."""
|
|
1003
953
|
await _AGENT_RUNTIME.run_to_completion(ctx, req, lang_hint, current_user, max_steps, max_retry)
|
|
1004
|
-
|
|
954
|
+
schedule_background_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
|
|
1005
955
|
|
|
1006
956
|
message = ctx.final_message or "작업을 완료했습니다."
|
|
1007
|
-
save_to_history
|
|
1008
|
-
save_to_history
|
|
957
|
+
await asyncio.to_thread(save_to_history, "user", req.message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
|
|
958
|
+
await asyncio.to_thread(save_to_history, "assistant", message, source=req.source or "web", conversation_id=req.conversation_id, workspace_id=req.workspace_id)
|
|
1009
959
|
try:
|
|
1010
960
|
WORKSPACE_OS.record_agent_run(
|
|
1011
961
|
agent_id="agent:executor",
|