ltcai 8.9.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 +28 -39
- package/auto_setup.py +11 -62
- package/docs/CHANGELOG.md +76 -237
- package/docs/COMMUNITY_AND_PLUGINS.md +2 -2
- package/docs/DEVELOPMENT.md +8 -8
- package/docs/LEGACY_COMPATIBILITY.md +1 -1
- package/docs/ONBOARDING.md +2 -2
- package/docs/ROADMAP_RECOMMENDATIONS.md +61 -36
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +2 -2
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +4 -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 +297 -0
- package/lattice_brain/graph/discovery.py +0 -948
- package/lattice_brain/graph/discovery_index.py +972 -0
- package/lattice_brain/graph/retrieval.py +0 -570
- package/lattice_brain/graph/retrieval_docgen.py +210 -0
- package/lattice_brain/graph/retrieval_vector.py +460 -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 +340 -407
- 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/permissions.py +3 -3
- package/latticeai/api/tools.py +1 -0
- package/latticeai/app_factory.py +82 -360
- 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/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/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/architecture_readiness.py +2 -2
- package/latticeai/services/memory_service.py +213 -0
- package/latticeai/services/platform_runtime.py +9 -1
- 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/setup_detection.py +80 -0
- package/latticeai/services/tool_dispatch.py +1 -1
- package/package.json +1 -1
- package/setup_wizard.py +11 -55
- 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-fZokUnC0.js → Act-21lIXx2E.js} +1 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +321 -0
- package/static/app/assets/{Capture-D5KV3Cu7.js → Capture-BA7Z2Q1u.js} +1 -1
- package/static/app/assets/{Library-C9kyFkSt.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-CD8UNKIy.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-DtyuWubr.js +0 -321
- 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/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
|
-
|
|
135
94
|
|
|
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:
|
|
@@ -298,6 +148,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
298
148
|
_doc_gen_sessions: dict = {}
|
|
299
149
|
_pending_agents: dict[str, tuple] = {}
|
|
300
150
|
_pending_agents_lock = threading.Lock()
|
|
151
|
+
_background_tasks: set[asyncio.Task] = set()
|
|
301
152
|
|
|
302
153
|
on_chat_message = context.on_chat_message
|
|
303
154
|
|
|
@@ -315,6 +166,232 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
315
166
|
except Exception as exc:
|
|
316
167
|
logging.warning("chat message bridge failed: %s", exc)
|
|
317
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
|
+
|
|
318
395
|
def history_scope_for_user(user_email: Optional[str]) -> Dict:
|
|
319
396
|
require_auth = bool(getattr(CONFIG, "require_auth", False))
|
|
320
397
|
scoped_user = user_email if require_auth else None
|
|
@@ -415,9 +492,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
415
492
|
current_user = require_user(request)
|
|
416
493
|
enforce_rate_limit(current_user, "chat")
|
|
417
494
|
img_len = len(req.image_data) if req.image_data else 0
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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 ""),
|
|
421
500
|
)
|
|
422
501
|
effective_email = req.user_email or current_user or None
|
|
423
502
|
history_user = get_history_user(effective_email, req.user_nickname)
|
|
@@ -428,162 +507,21 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
428
507
|
}
|
|
429
508
|
|
|
430
509
|
if is_network_status_request(req.message):
|
|
431
|
-
|
|
432
|
-
save_to_history("user", history_message, **history_meta, **history_user)
|
|
433
|
-
try:
|
|
434
|
-
answer = format_network_status(network_status())
|
|
435
|
-
except ToolError as exc:
|
|
436
|
-
answer = f"네트워크 정보를 확인하지 못했습니다: {exc}"
|
|
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})
|
|
510
|
+
return await handle_network_status_intent(req, history_meta=history_meta, history_user=history_user)
|
|
447
511
|
|
|
448
512
|
if is_clear_command(req.message):
|
|
449
|
-
|
|
450
|
-
clear_scope = "all" if command == "/clear_all" else "conversation"
|
|
451
|
-
if ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
452
|
-
try:
|
|
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),
|
|
484
|
-
)
|
|
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})
|
|
513
|
+
return await handle_clear_intent(req, effective_email=effective_email)
|
|
492
514
|
|
|
493
515
|
if is_current_url_request(req.message) and req.client_url:
|
|
494
|
-
|
|
495
|
-
save_to_history("user", req.message, **history_meta, **history_user)
|
|
496
|
-
save_to_history("assistant", answer, **history_meta, **history_user)
|
|
497
|
-
notify_chat_message("user", req.message, req.source)
|
|
498
|
-
notify_chat_message("assistant", answer, req.source)
|
|
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})
|
|
516
|
+
return await handle_current_url_intent(req, history_meta=history_meta, history_user=history_user)
|
|
506
517
|
|
|
507
518
|
if is_file_action_request(req.message):
|
|
508
|
-
|
|
509
|
-
if
|
|
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)
|
|
519
|
+
direct_response = await handle_direct_file_action(req)
|
|
520
|
+
if direct_response is not None:
|
|
521
|
+
return direct_response
|
|
573
522
|
|
|
574
523
|
if not router.current_model_id:
|
|
575
|
-
|
|
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
|
-
},
|
|
586
|
-
)
|
|
524
|
+
return no_model_response()
|
|
587
525
|
|
|
588
526
|
if req.model and req.model != router.current_model_id:
|
|
589
527
|
if req.model not in router.loaded_model_ids:
|
|
@@ -591,32 +529,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
591
529
|
router.switch_model(req.model)
|
|
592
530
|
|
|
593
531
|
if is_file_action_request(req.message):
|
|
594
|
-
|
|
595
|
-
message=req.message,
|
|
596
|
-
conversation_id=req.conversation_id,
|
|
597
|
-
source=req.source or "web",
|
|
598
|
-
max_steps=25,
|
|
599
|
-
temperature=min(req.temperature, 0.2),
|
|
600
|
-
user_email=effective_email,
|
|
601
|
-
user_nickname=req.user_nickname,
|
|
602
|
-
workspace_id=workspace_scope_from_request(request),
|
|
603
|
-
)
|
|
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)
|
|
532
|
+
return await route_file_action_to_agent(req, request, effective_email=effective_email)
|
|
620
533
|
|
|
621
534
|
lang = detect_language(req.message)
|
|
622
535
|
context = f"[LANGUAGE: {_LANG_HINT[lang]}]\n" + (req.context or "")
|
|
@@ -651,7 +564,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
651
564
|
graph_md = doc_gen_context_result.get("context_markdown", "")
|
|
652
565
|
if graph_md:
|
|
653
566
|
context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
|
|
654
|
-
|
|
567
|
+
logging.debug("Document generation context retrieved from knowledge graph.")
|
|
655
568
|
except Exception as e:
|
|
656
569
|
logging.warning("Knowledge graph reinforcement skipped: %s", e)
|
|
657
570
|
|
|
@@ -691,7 +604,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
691
604
|
trace_seed["context_assembly"] = context_trace
|
|
692
605
|
|
|
693
606
|
history_message = f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
694
|
-
save_to_history
|
|
607
|
+
await asyncio.to_thread(save_to_history, "user", history_message, **history_meta, **history_user)
|
|
695
608
|
notify_chat_message("user", req.message, req.source)
|
|
696
609
|
|
|
697
610
|
if is_doc_gen and ENABLE_GRAPH and KNOWLEDGE_GRAPH:
|
|
@@ -708,19 +621,27 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
708
621
|
if req.stream:
|
|
709
622
|
async def _stream_doc_gen():
|
|
710
623
|
collected = []
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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"
|
|
718
637
|
full_text = "".join(collected)
|
|
719
638
|
if footnote:
|
|
720
639
|
yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
|
|
721
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}"
|
|
722
643
|
session.update(graph_md, full_text, req.conversation_id)
|
|
723
|
-
save_to_history
|
|
644
|
+
await asyncio.to_thread(save_to_history, "assistant", full_text, **history_meta, **history_user)
|
|
724
645
|
trace_record = CHAT_SERVICE.record_trace(
|
|
725
646
|
question=req.message,
|
|
726
647
|
response=full_text,
|
|
@@ -745,7 +666,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
745
666
|
if footnote:
|
|
746
667
|
result += footnote
|
|
747
668
|
session.update(graph_md, result, req.conversation_id)
|
|
748
|
-
save_to_history
|
|
669
|
+
await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
|
|
749
670
|
trace_record = CHAT_SERVICE.record_trace(
|
|
750
671
|
question=req.message,
|
|
751
672
|
response=str(result),
|
|
@@ -788,7 +709,7 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
788
709
|
|
|
789
710
|
result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
|
|
790
711
|
|
|
791
|
-
save_to_history
|
|
712
|
+
await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
|
|
792
713
|
trace_record = CHAT_SERVICE.record_trace(
|
|
793
714
|
question=req.message,
|
|
794
715
|
response=str(result),
|
|
@@ -883,33 +804,45 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
883
804
|
history_meta: Optional[Dict] = None,
|
|
884
805
|
) -> AsyncIterator[str]:
|
|
885
806
|
full_response = ""
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
clean_chunk = chunk.text
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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"
|
|
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"
|
|
898
817
|
history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
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"
|
|
913
846
|
yield "data: [DONE]\n\n"
|
|
914
847
|
|
|
915
848
|
@api_router.post("/agent/eval")
|
|
@@ -1018,11 +951,11 @@ def create_chat_router(context: AppContext) -> APIRouter:
|
|
|
1018
951
|
) -> dict:
|
|
1019
952
|
"""HTTP glue: drive the runtime to a terminal state, persist, shape the response."""
|
|
1020
953
|
await _AGENT_RUNTIME.run_to_completion(ctx, req, lang_hint, current_user, max_steps, max_retry)
|
|
1021
|
-
|
|
954
|
+
schedule_background_task(_AGENT_RUNTIME.memory_update(ctx, req, current_user))
|
|
1022
955
|
|
|
1023
956
|
message = ctx.final_message or "작업을 완료했습니다."
|
|
1024
|
-
save_to_history
|
|
1025
|
-
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)
|
|
1026
959
|
try:
|
|
1027
960
|
WORKSPACE_OS.record_agent_run(
|
|
1028
961
|
agent_id="agent:executor",
|