ltcai 9.0.0 → 9.2.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 +88 -46
- package/auto_setup.py +7 -853
- package/desktop/electron/README.md +9 -0
- package/desktop/electron/main.cjs +5 -3
- package/docs/CHANGELOG.md +178 -2
- package/docs/COMMUNITY_AND_PLUGINS.md +4 -2
- package/docs/DEVELOPMENT.md +53 -14
- package/docs/LEGACY_COMPATIBILITY.md +4 -4
- package/docs/ONBOARDING.md +10 -2
- package/docs/OPERATIONS.md +8 -4
- package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
- package/docs/TRUST_MODEL.md +5 -2
- package/docs/WHY_LATTICE.md +15 -10
- package/docs/WORKFLOW_DESIGNER.md +22 -0
- package/docs/kg-schema.md +13 -2
- package/docs/mcp-tools.md +17 -6
- package/docs/privacy.md +19 -3
- package/docs/public-deploy.md +32 -3
- package/docs/security-model.md +46 -11
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/archive.py +1 -6
- package/lattice_brain/context.py +66 -9
- package/lattice_brain/graph/_kg_fsutil.py +1 -5
- package/lattice_brain/graph/discovery_index.py +174 -20
- package/lattice_brain/graph/documents.py +44 -9
- package/lattice_brain/graph/ingest.py +47 -20
- package/lattice_brain/graph/provenance.py +13 -6
- package/lattice_brain/graph/retrieval.py +140 -39
- package/lattice_brain/graph/retrieval_docgen.py +37 -4
- package/lattice_brain/ingestion.py +4 -7
- package/lattice_brain/portability.py +28 -14
- package/lattice_brain/runtime/agent_runtime.py +5 -9
- package/lattice_brain/runtime/hooks.py +30 -13
- package/lattice_brain/runtime/multi_agent.py +27 -8
- package/lattice_brain/runtime/statuses.py +10 -0
- package/lattice_brain/utils.py +20 -2
- package/lattice_brain/workflow.py +1 -5
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/admin.py +1 -1
- package/latticeai/api/agent_registry.py +10 -8
- package/latticeai/api/agents.py +22 -3
- package/latticeai/api/auth.py +78 -16
- package/latticeai/api/browser.py +303 -34
- package/latticeai/api/chat.py +320 -850
- package/latticeai/api/chat_agent_http.py +356 -0
- package/latticeai/api/chat_contracts.py +54 -0
- package/latticeai/api/chat_documents.py +256 -0
- package/latticeai/api/chat_helpers.py +28 -1
- package/latticeai/api/chat_history.py +99 -0
- package/latticeai/api/chat_intents.py +316 -0
- package/latticeai/api/chat_stream.py +115 -0
- package/latticeai/api/computer_use.py +62 -9
- package/latticeai/api/health.py +9 -3
- package/latticeai/api/hooks.py +17 -6
- package/latticeai/api/knowledge_graph.py +78 -14
- package/latticeai/api/local_files.py +7 -2
- package/latticeai/api/mcp.py +102 -23
- package/latticeai/api/models.py +72 -33
- package/latticeai/api/network.py +5 -5
- package/latticeai/api/permissions.py +67 -26
- package/latticeai/api/plugins.py +21 -7
- package/latticeai/api/portability.py +5 -5
- package/latticeai/api/realtime.py +33 -5
- package/latticeai/api/setup.py +2 -2
- package/latticeai/api/static_routes.py +123 -24
- package/latticeai/api/tools.py +96 -14
- package/latticeai/api/workflow_designer.py +37 -0
- package/latticeai/api/workspace.py +2 -1
- package/latticeai/app_factory.py +110 -52
- package/latticeai/core/agent.py +50 -13
- package/latticeai/core/agent_prompts.py +5 -0
- package/latticeai/core/agent_registry.py +1 -5
- package/latticeai/core/config.py +23 -3
- package/latticeai/core/context_builder.py +21 -3
- package/latticeai/core/file_generation.py +451 -0
- package/latticeai/core/invitations.py +1 -4
- package/latticeai/core/io_utils.py +9 -1
- package/latticeai/core/legacy_compatibility.py +24 -3
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/plugins.py +15 -0
- package/latticeai/core/policy.py +1 -1
- package/latticeai/core/realtime.py +38 -15
- package/latticeai/core/sessions.py +7 -2
- package/latticeai/core/timeutil.py +10 -0
- package/latticeai/core/tool_registry.py +90 -18
- package/latticeai/core/users.py +1 -5
- package/latticeai/core/workspace_graph_trace.py +31 -5
- package/latticeai/core/workspace_memory.py +3 -1
- package/latticeai/core/workspace_os.py +18 -7
- package/latticeai/core/workspace_os_utils.py +0 -5
- package/latticeai/core/workspace_permissions.py +2 -1
- package/latticeai/core/workspace_plugins.py +1 -1
- package/latticeai/core/workspace_runs.py +14 -5
- package/latticeai/core/workspace_skills.py +1 -1
- package/latticeai/core/workspace_snapshots.py +2 -1
- package/latticeai/core/workspace_timeline.py +2 -1
- package/latticeai/integrations/telegram_bot.py +94 -43
- package/latticeai/models/router.py +130 -36
- package/latticeai/runtime/access_runtime.py +62 -4
- package/latticeai/runtime/automation_runtime.py +13 -7
- package/latticeai/runtime/brain_runtime.py +19 -7
- package/latticeai/runtime/chat_wiring.py +2 -0
- package/latticeai/runtime/config_runtime.py +58 -26
- package/latticeai/runtime/context_runtime.py +16 -4
- package/latticeai/runtime/hooks_runtime.py +1 -1
- package/latticeai/runtime/model_wiring.py +33 -5
- package/latticeai/runtime/namespace_runtime.py +62 -72
- package/latticeai/runtime/platform_runtime_wiring.py +4 -3
- package/latticeai/runtime/review_wiring.py +1 -1
- package/latticeai/runtime/router_registration.py +34 -1
- package/latticeai/runtime/security_runtime.py +110 -13
- package/latticeai/runtime/stages.py +27 -0
- package/latticeai/server_app.py +5 -1
- package/latticeai/services/architecture_readiness.py +74 -5
- package/latticeai/services/brain_automation.py +3 -26
- package/latticeai/services/chat_service.py +205 -15
- package/latticeai/services/local_knowledge.py +423 -0
- package/latticeai/services/memory_service.py +55 -21
- package/latticeai/services/model_engines.py +48 -33
- package/latticeai/services/model_errors.py +17 -0
- package/latticeai/services/model_loading.py +28 -25
- package/latticeai/services/model_runtime.py +228 -162
- package/latticeai/services/p_reinforce.py +22 -3
- package/latticeai/services/platform_runtime.py +83 -23
- package/latticeai/services/product_readiness.py +19 -17
- package/latticeai/services/review_queue.py +12 -0
- package/latticeai/services/router_context.py +1 -0
- package/latticeai/services/run_executor.py +4 -9
- package/latticeai/services/search_service.py +203 -28
- package/latticeai/services/tool_dispatch.py +28 -5
- package/latticeai/services/triggers.py +53 -4
- package/latticeai/services/upload_service.py +13 -2
- package/latticeai/setup/__init__.py +25 -0
- package/latticeai/setup/auto_setup.py +857 -0
- package/latticeai/setup/wizard.py +1264 -0
- package/latticeai/tools/__init__.py +278 -0
- package/{tools → latticeai/tools}/commands.py +67 -7
- package/{tools → latticeai/tools}/computer.py +1 -1
- package/{tools → latticeai/tools}/documents.py +1 -1
- package/{tools → latticeai/tools}/filesystem.py +3 -3
- package/latticeai/tools/knowledge.py +178 -0
- package/{tools → latticeai/tools}/local_files.py +1 -1
- package/{tools → latticeai/tools}/network.py +0 -1
- package/local_knowledge_api.py +4 -342
- package/package.json +22 -2
- package/scripts/brain_quality_eval.py +3 -1
- package/scripts/bump_version.py +3 -0
- package/scripts/capture_release_evidence.mjs +180 -0
- package/scripts/check_current_release_docs.mjs +142 -0
- package/scripts/check_i18n_literals.mjs +107 -31
- package/scripts/check_openapi_drift.mjs +56 -0
- package/scripts/export_openapi.py +67 -7
- package/scripts/i18n_literal_allowlist.json +22 -24
- package/scripts/run_integration_tests.mjs +72 -10
- package/scripts/validate_release_artifacts.py +49 -7
- package/scripts/wheel_smoke.py +5 -0
- package/setup_wizard.py +3 -1260
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +11 -11
- package/static/app/assets/Act-C3dBrWE-.js +1 -0
- package/static/app/assets/Brain-DBYgdcjt.js +321 -0
- package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
- package/static/app/assets/Library-CFfkNn3s.js +1 -0
- package/static/app/assets/System-BOurbT-v.js +1 -0
- package/static/app/assets/index-A3M9sElj.js +17 -0
- package/static/app/assets/index-Bmx9rzTc.css +2 -0
- package/static/app/assets/primitives-DcUUmhdC.js +1 -0
- package/static/app/assets/textarea-BklR6zN4.js +1 -0
- package/static/app/index.html +2 -2
- package/static/sw.js +1 -1
- package/tools/__init__.py +21 -269
- package/docs/CODE_REVIEW_2026-07-06.md +0 -764
- package/latticeai/runtime/app_context_runtime.py +0 -13
- package/latticeai/runtime/tail_wiring.py +0 -21
- package/scripts/com.pts.claudecode.discord.plist +0 -31
- package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
- package/scripts/start-pts-claudecode-discord.sh +0 -51
- package/static/app/assets/Act-21lIXx2E.js +0 -1
- package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
- package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
- package/static/app/assets/Library-bFMtyni3.js +0 -1
- package/static/app/assets/System-K6krGCqn.js +0 -1
- package/static/app/assets/index-C4R3ws30.js +0 -17
- package/static/app/assets/index-ChSeOB02.css +0 -2
- package/static/app/assets/primitives-sQU3it5I.js +0 -1
- package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
- package/tools/knowledge.py +0 -95
package/latticeai/api/chat.py
CHANGED
|
@@ -1,36 +1,31 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Composition root for chat, history, document, and local-agent routes.
|
|
2
|
+
|
|
3
|
+
The stable public factory remains ``create_chat_router(AppContext)``. Feature
|
|
4
|
+
implementation is split across focused HTTP modules while this file owns only
|
|
5
|
+
dependency assembly and the top-level chat request pipeline.
|
|
6
|
+
"""
|
|
2
7
|
|
|
3
8
|
from __future__ import annotations
|
|
4
9
|
|
|
5
|
-
import asyncio
|
|
6
|
-
import base64
|
|
7
|
-
import io
|
|
8
|
-
import json
|
|
9
10
|
import logging
|
|
10
11
|
import re
|
|
11
|
-
import secrets
|
|
12
|
-
import shutil
|
|
13
|
-
import subprocess
|
|
14
|
-
import tempfile
|
|
15
|
-
import threading
|
|
16
12
|
from pathlib import Path
|
|
17
|
-
from typing import
|
|
13
|
+
from typing import Dict, Optional
|
|
18
14
|
|
|
19
15
|
from fastapi import APIRouter, HTTPException, Request
|
|
20
16
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
21
|
-
from pydantic import BaseModel
|
|
22
|
-
from PIL import Image
|
|
23
17
|
|
|
24
|
-
from latticeai.
|
|
25
|
-
from latticeai.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
18
|
+
from latticeai.api.chat_agent_http import AgentHTTPController
|
|
19
|
+
from latticeai.api.chat_contracts import (
|
|
20
|
+
AgentEvalRequest,
|
|
21
|
+
AgentRequest,
|
|
22
|
+
AgentResumeRequest,
|
|
23
|
+
ChatRequest,
|
|
24
|
+
)
|
|
25
|
+
from latticeai.api.chat_documents import (
|
|
26
|
+
DocumentGenerationCoordinator,
|
|
27
|
+
extract_screenshot_context,
|
|
28
|
+
)
|
|
34
29
|
from latticeai.api.chat_helpers import (
|
|
35
30
|
_LANG_HINT,
|
|
36
31
|
build_recent_chat_context,
|
|
@@ -47,55 +42,27 @@ from latticeai.api.chat_helpers import (
|
|
|
47
42
|
strip_generated_file_content,
|
|
48
43
|
workspace_scope_from_request,
|
|
49
44
|
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
allow_file_context: bool = False
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class AgentRequest(BaseModel):
|
|
68
|
-
message: str
|
|
69
|
-
conversation_id: Optional[str] = None
|
|
70
|
-
source: Optional[str] = None
|
|
71
|
-
max_steps: int = 25
|
|
72
|
-
temperature: float = 0.1
|
|
73
|
-
user_email: Optional[str] = None
|
|
74
|
-
user_nickname: Optional[str] = None
|
|
75
|
-
workspace_id: Optional[str] = None
|
|
76
|
-
planning_model: Optional[str] = None
|
|
77
|
-
executing_model: Optional[str] = None
|
|
78
|
-
reviewing_model: Optional[str] = None
|
|
79
|
-
human_in_loop: bool = False
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class AgentResumeRequest(BaseModel):
|
|
83
|
-
context_id: str
|
|
84
|
-
approved: bool = True
|
|
85
|
-
modified_plan: Optional[dict] = None
|
|
86
|
-
executing_model: Optional[str] = None
|
|
87
|
-
reviewing_model: Optional[str] = None
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
class AgentEvalRequest(BaseModel):
|
|
91
|
-
skill: str
|
|
92
|
-
case_id: Optional[str] = None
|
|
45
|
+
from latticeai.api.chat_history import HistoryRouteDependencies, register_history_routes
|
|
46
|
+
from latticeai.api.chat_intents import ChatIntentController
|
|
47
|
+
from latticeai.api.chat_stream import stream_chat
|
|
48
|
+
from latticeai.services.app_context import AppContext
|
|
49
|
+
from latticeai.services.chat_service import ChatService
|
|
50
|
+
from latticeai.services.tool_dispatch import build_agent_runtime, enforce_tool_policy
|
|
51
|
+
from latticeai.tools import (
|
|
52
|
+
AGENT_ROOT,
|
|
53
|
+
ToolError,
|
|
54
|
+
ensure_agent_root,
|
|
55
|
+
execute_tool,
|
|
56
|
+
knowledge_save,
|
|
57
|
+
network_status,
|
|
58
|
+
)
|
|
93
59
|
|
|
94
60
|
|
|
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
61
|
__all__ = [
|
|
62
|
+
"AgentEvalRequest",
|
|
63
|
+
"AgentRequest",
|
|
64
|
+
"AgentResumeRequest",
|
|
65
|
+
"ChatRequest",
|
|
99
66
|
"create_chat_router",
|
|
100
67
|
"build_recent_chat_context",
|
|
101
68
|
"pair_user_history",
|
|
@@ -114,900 +81,403 @@ __all__ = [
|
|
|
114
81
|
|
|
115
82
|
|
|
116
83
|
def create_chat_router(context: AppContext) -> APIRouter:
|
|
117
|
-
"""Build the chat/history/agent
|
|
84
|
+
"""Build the unchanged chat/history/agent route surface."""
|
|
118
85
|
|
|
119
|
-
Replaces the historical ~25-kwarg factory signature: ``context``
|
|
120
|
-
(:class:`latticeai.services.app_context.AppContext`) carries the same
|
|
121
|
-
dependencies as typed fields.
|
|
122
|
-
"""
|
|
123
86
|
api_router = APIRouter()
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
workspace_graph = context.workspace_graph
|
|
127
|
-
context_assembler = context.context_assembler
|
|
87
|
+
model_router = context.model_router
|
|
88
|
+
config = context.config
|
|
128
89
|
require_user = context.require_user
|
|
129
|
-
|
|
130
|
-
get_history_user = context.get_history_user
|
|
131
|
-
save_to_history = context.save_to_history
|
|
132
|
-
append_audit_event = context.append_audit_event
|
|
133
|
-
clear_history = context.clear_history
|
|
134
|
-
clear_conversation = context.clear_conversation
|
|
135
|
-
get_history = context.get_history
|
|
136
|
-
group_history_conversations = context.group_history_conversations
|
|
137
|
-
get_conversation_messages = context.get_conversation_messages
|
|
138
|
-
conversation_title = context.conversation_title
|
|
90
|
+
workspace_service = context.workspace_service
|
|
139
91
|
allowed_workspaces_for = context.allowed_workspaces_for
|
|
140
92
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
_doc_gen_sessions: dict = {}
|
|
149
|
-
_pending_agents: dict[str, tuple] = {}
|
|
150
|
-
_pending_agents_lock = threading.Lock()
|
|
151
|
-
_background_tasks: set[asyncio.Task] = set()
|
|
152
|
-
|
|
153
|
-
on_chat_message = context.on_chat_message
|
|
93
|
+
chat_service = ChatService.coerce(
|
|
94
|
+
context.chat_service,
|
|
95
|
+
store=context.workspace_store,
|
|
96
|
+
get_history=context.get_history,
|
|
97
|
+
save_to_history=context.save_to_history,
|
|
98
|
+
get_history_user=context.get_history_user,
|
|
99
|
+
)
|
|
154
100
|
|
|
155
101
|
def notify_chat_message(role: str, text: str, source: Optional[str]) -> None:
|
|
156
|
-
"""Mirror
|
|
157
|
-
|
|
158
|
-
``on_chat_message`` is registered by ``create_app`` only when
|
|
159
|
-
ENABLE_TELEGRAM is truthy; exchanges that originated *from* telegram
|
|
160
|
-
are never echoed back. No bridge registered → no-op.
|
|
161
|
-
"""
|
|
162
|
-
if on_chat_message is None or source == "telegram":
|
|
102
|
+
"""Mirror persisted web exchanges to an injected bridge, never echoing it."""
|
|
103
|
+
if context.on_chat_message is None or source == "telegram":
|
|
163
104
|
return
|
|
164
105
|
try:
|
|
165
|
-
on_chat_message(role, text, source)
|
|
106
|
+
context.on_chat_message(role, text, source)
|
|
166
107
|
except Exception as exc:
|
|
167
108
|
logging.warning("chat message bridge failed: %s", exc)
|
|
168
109
|
|
|
169
|
-
def
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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},
|
|
110
|
+
def authenticated_identity(
|
|
111
|
+
current_user: str,
|
|
112
|
+
claimed_email: Optional[str],
|
|
113
|
+
) -> Optional[str]:
|
|
114
|
+
if current_user and claimed_email:
|
|
115
|
+
if current_user.strip().lower() != claimed_email.strip().lower():
|
|
116
|
+
raise HTTPException(
|
|
117
|
+
status_code=403,
|
|
118
|
+
detail="user_email must match the authenticated user.",
|
|
263
119
|
)
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.")
|
|
120
|
+
return current_user or claimed_email or None
|
|
121
|
+
|
|
122
|
+
def write_workspace(
|
|
123
|
+
requested: Optional[str],
|
|
124
|
+
current_user: str,
|
|
125
|
+
) -> Optional[str]:
|
|
126
|
+
if workspace_service is None:
|
|
127
|
+
return requested
|
|
329
128
|
try:
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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"},
|
|
129
|
+
return workspace_service.resolve_write_scope(
|
|
130
|
+
requested,
|
|
131
|
+
current_user or None,
|
|
392
132
|
)
|
|
393
|
-
|
|
133
|
+
except PermissionError as exc:
|
|
134
|
+
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
|
394
135
|
|
|
395
136
|
def history_scope_for_user(user_email: Optional[str]) -> Dict:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
return {
|
|
402
|
-
"user_email": scoped_user,
|
|
403
|
-
"allowed_workspaces": allowed,
|
|
404
|
-
"include_legacy_global": not require_auth,
|
|
405
|
-
}
|
|
137
|
+
return chat_service.history_scope(
|
|
138
|
+
user_email,
|
|
139
|
+
require_auth=bool(getattr(config, "require_auth", False)),
|
|
140
|
+
allowed_workspaces_for=allowed_workspaces_for,
|
|
141
|
+
)
|
|
406
142
|
|
|
407
143
|
def recent_chat_context(
|
|
408
144
|
limit: int = 10,
|
|
409
145
|
include_image_missing_replies: bool = True,
|
|
410
146
|
user_email: Optional[str] = None,
|
|
411
147
|
conversation_id: Optional[str] = None,
|
|
148
|
+
workspace_id: Optional[str] = None,
|
|
412
149
|
) -> str:
|
|
413
150
|
return build_recent_chat_context(
|
|
414
|
-
get_history=get_history,
|
|
151
|
+
get_history=context.get_history,
|
|
415
152
|
limit=limit,
|
|
416
153
|
include_image_missing_replies=include_image_missing_replies,
|
|
417
154
|
user_email=user_email,
|
|
418
155
|
conversation_id=conversation_id,
|
|
156
|
+
workspace_id=workspace_id,
|
|
419
157
|
)
|
|
420
158
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
try:
|
|
428
|
-
image_bytes = base64.b64decode(image_data)
|
|
429
|
-
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
|
430
|
-
lines.append(f"- image_size: {image.width}x{image.height}")
|
|
431
|
-
lines.append(f"- image_mode: {image.mode}")
|
|
432
|
-
except Exception as e:
|
|
433
|
-
lines.append(f"- image_decode_error: {e}")
|
|
434
|
-
return "\n".join(lines)
|
|
435
|
-
|
|
436
|
-
tesseract_path = shutil.which("tesseract")
|
|
437
|
-
if not tesseract_path:
|
|
438
|
-
lines.append("- ocr: unavailable; install `tesseract` to enable OCR text extraction.")
|
|
439
|
-
return "\n".join(lines)
|
|
440
|
-
|
|
441
|
-
temp_path = None
|
|
442
|
-
try:
|
|
443
|
-
with tempfile.NamedTemporaryFile(prefix="ltcai-screenshot-", suffix=".png", delete=False) as temp:
|
|
444
|
-
temp.write(image_bytes)
|
|
445
|
-
temp_path = temp.name
|
|
446
|
-
|
|
447
|
-
ocr_text = ""
|
|
448
|
-
for lang in ("kor+eng", "eng"):
|
|
449
|
-
completed = subprocess.run(
|
|
450
|
-
[tesseract_path, temp_path, "stdout", "-l", lang, "--psm", "6"],
|
|
451
|
-
capture_output=True,
|
|
452
|
-
text=True,
|
|
453
|
-
timeout=20,
|
|
454
|
-
check=False,
|
|
455
|
-
)
|
|
456
|
-
if completed.returncode == 0 and completed.stdout.strip():
|
|
457
|
-
ocr_text = completed.stdout.strip()
|
|
458
|
-
lines.append(f"- ocr_language: {lang}")
|
|
459
|
-
break
|
|
460
|
-
|
|
461
|
-
if ocr_text:
|
|
462
|
-
lines.append("- ocr_text:")
|
|
463
|
-
lines.append(ocr_text[:4000])
|
|
464
|
-
else:
|
|
465
|
-
lines.append("- ocr: no text extracted.")
|
|
466
|
-
except Exception as e:
|
|
467
|
-
lines.append(f"- ocr_error: {e}")
|
|
468
|
-
finally:
|
|
469
|
-
if temp_path:
|
|
470
|
-
try:
|
|
471
|
-
Path(temp_path).unlink()
|
|
472
|
-
except OSError:
|
|
473
|
-
pass
|
|
474
|
-
|
|
475
|
-
return "\n".join(lines)
|
|
476
|
-
|
|
477
|
-
_AGENT_RUNTIME = context.chat_agent_runtime
|
|
478
|
-
if _AGENT_RUNTIME is None:
|
|
479
|
-
_AGENT_RUNTIME = build_agent_runtime(
|
|
480
|
-
model_router=router,
|
|
159
|
+
agent_runtime = context.chat_agent_runtime
|
|
160
|
+
if agent_runtime is None:
|
|
161
|
+
# Keep construction here so the historical monkeypatch/import seam on
|
|
162
|
+
# latticeai.api.chat.build_agent_runtime remains valid.
|
|
163
|
+
agent_runtime = build_agent_runtime(
|
|
164
|
+
model_router=model_router,
|
|
481
165
|
execute_tool=execute_tool,
|
|
482
166
|
recent_chat_context=recent_chat_context,
|
|
483
|
-
clear_history=clear_history,
|
|
167
|
+
clear_history=context.clear_history,
|
|
484
168
|
knowledge_save=knowledge_save,
|
|
485
|
-
audit=append_audit_event,
|
|
486
|
-
hooks=hooks,
|
|
169
|
+
audit=context.append_audit_event,
|
|
170
|
+
hooks=context.hooks,
|
|
487
171
|
brain_memory=context.brain_memory,
|
|
488
172
|
)
|
|
489
173
|
|
|
174
|
+
agent_controller = AgentHTTPController(
|
|
175
|
+
runtime=agent_runtime,
|
|
176
|
+
model_router=model_router,
|
|
177
|
+
require_user=require_user,
|
|
178
|
+
require_admin=context.require_admin,
|
|
179
|
+
enforce_rate_limit=context.enforce_rate_limit,
|
|
180
|
+
authenticated_identity=authenticated_identity,
|
|
181
|
+
write_workspace=write_workspace,
|
|
182
|
+
save_to_history=context.save_to_history,
|
|
183
|
+
workspace_store=context.workspace_store,
|
|
184
|
+
workspace_graph=context.workspace_graph,
|
|
185
|
+
hooks=context.hooks,
|
|
186
|
+
execute_tool=execute_tool,
|
|
187
|
+
base_dir=context.base_dir or Path.cwd(),
|
|
188
|
+
agent_root=AGENT_ROOT,
|
|
189
|
+
ensure_agent_root=ensure_agent_root,
|
|
190
|
+
)
|
|
191
|
+
intent_controller = ChatIntentController(
|
|
192
|
+
model_router=model_router,
|
|
193
|
+
config=config,
|
|
194
|
+
public_model=context.public_model,
|
|
195
|
+
chat_service=chat_service,
|
|
196
|
+
notify=notify_chat_message,
|
|
197
|
+
clear_history=context.clear_history,
|
|
198
|
+
clear_conversation=context.clear_conversation,
|
|
199
|
+
history_scope_for_user=history_scope_for_user,
|
|
200
|
+
append_audit_event=context.append_audit_event,
|
|
201
|
+
enable_graph=context.enable_graph,
|
|
202
|
+
knowledge_graph=context.knowledge_graph,
|
|
203
|
+
enforce_tool_policy=enforce_tool_policy,
|
|
204
|
+
network_status=network_status,
|
|
205
|
+
tool_error=ToolError,
|
|
206
|
+
execute_tool=execute_tool,
|
|
207
|
+
agent_controller=agent_controller,
|
|
208
|
+
agent_root=AGENT_ROOT,
|
|
209
|
+
)
|
|
210
|
+
document_coordinator = DocumentGenerationCoordinator(
|
|
211
|
+
model_router=model_router,
|
|
212
|
+
knowledge_graph=context.knowledge_graph,
|
|
213
|
+
enable_graph=context.enable_graph,
|
|
214
|
+
chat_service=chat_service,
|
|
215
|
+
notify=notify_chat_message,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def request_model(model_id: Optional[str]) -> Optional[str]:
|
|
219
|
+
selected = model_id or model_router.current_model_id
|
|
220
|
+
if model_id and model_id not in model_router.loaded_model_ids:
|
|
221
|
+
raise HTTPException(
|
|
222
|
+
status_code=404,
|
|
223
|
+
detail=f"Model '{model_id}' not loaded.",
|
|
224
|
+
)
|
|
225
|
+
return selected
|
|
226
|
+
|
|
490
227
|
@api_router.post("/chat")
|
|
491
228
|
async def chat(req: ChatRequest, request: Request):
|
|
492
229
|
current_user = require_user(request)
|
|
493
|
-
enforce_rate_limit(current_user, "chat")
|
|
494
|
-
img_len = len(req.image_data) if req.image_data else 0
|
|
230
|
+
context.enforce_rate_limit(current_user, "chat")
|
|
495
231
|
logging.debug(
|
|
496
232
|
"/chat request: stream=%s image_data_len=%s message_len=%s",
|
|
497
233
|
req.stream,
|
|
498
|
-
|
|
234
|
+
len(req.image_data) if req.image_data else 0,
|
|
499
235
|
len(req.message or ""),
|
|
500
236
|
)
|
|
501
|
-
effective_email = req.user_email
|
|
502
|
-
|
|
237
|
+
effective_email = authenticated_identity(current_user, req.user_email)
|
|
238
|
+
workspace_id = write_workspace(
|
|
239
|
+
workspace_scope_from_request(request),
|
|
240
|
+
current_user,
|
|
241
|
+
)
|
|
242
|
+
history_user = chat_service.history_user(
|
|
243
|
+
effective_email,
|
|
244
|
+
req.user_nickname,
|
|
245
|
+
)
|
|
503
246
|
history_meta = {
|
|
504
247
|
"source": req.source or "web",
|
|
505
248
|
"conversation_id": req.conversation_id,
|
|
506
|
-
"workspace_id":
|
|
249
|
+
"workspace_id": workspace_id,
|
|
507
250
|
}
|
|
508
251
|
|
|
509
252
|
if is_network_status_request(req.message):
|
|
510
|
-
return await
|
|
511
|
-
|
|
253
|
+
return await intent_controller.network(
|
|
254
|
+
req,
|
|
255
|
+
current_user=current_user,
|
|
256
|
+
history_meta=history_meta,
|
|
257
|
+
history_user=history_user,
|
|
258
|
+
)
|
|
512
259
|
if is_clear_command(req.message):
|
|
513
|
-
return await
|
|
514
|
-
|
|
260
|
+
return await intent_controller.clear(
|
|
261
|
+
req,
|
|
262
|
+
effective_email=effective_email,
|
|
263
|
+
workspace_id=workspace_id,
|
|
264
|
+
)
|
|
515
265
|
if is_current_url_request(req.message) and req.client_url:
|
|
516
|
-
return await
|
|
266
|
+
return await intent_controller.current_url(
|
|
267
|
+
req,
|
|
268
|
+
history_meta=history_meta,
|
|
269
|
+
history_user=history_user,
|
|
270
|
+
)
|
|
517
271
|
|
|
272
|
+
selected_model_id = request_model(req.model)
|
|
518
273
|
if is_file_action_request(req.message):
|
|
519
|
-
direct_response = await
|
|
274
|
+
direct_response = await intent_controller.direct_file_action(
|
|
275
|
+
req,
|
|
276
|
+
model_id=selected_model_id,
|
|
277
|
+
)
|
|
520
278
|
if direct_response is not None:
|
|
521
279
|
return direct_response
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
return no_model_response()
|
|
525
|
-
|
|
526
|
-
if req.model and req.model != router.current_model_id:
|
|
527
|
-
if req.model not in router.loaded_model_ids:
|
|
528
|
-
raise HTTPException(status_code=404, detail=f"Model '{req.model}' not loaded.")
|
|
529
|
-
router.switch_model(req.model)
|
|
530
|
-
|
|
280
|
+
if not selected_model_id:
|
|
281
|
+
return intent_controller.no_model_response()
|
|
531
282
|
if is_file_action_request(req.message):
|
|
532
|
-
return await
|
|
283
|
+
return await intent_controller.route_file_to_agent(
|
|
284
|
+
req,
|
|
285
|
+
request,
|
|
286
|
+
effective_email=effective_email,
|
|
287
|
+
workspace_id=workspace_id,
|
|
288
|
+
model_id=selected_model_id,
|
|
289
|
+
)
|
|
533
290
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
# v4 Context System: one budgeted, provenance-carrying assembly
|
|
537
|
-
# (workspace memories + hybrid search + garden notes) replaces the
|
|
538
|
-
# ad-hoc vault-scan + LIKE-search concatenation. The trace records
|
|
539
|
-
# why each section is in the prompt.
|
|
291
|
+
language = detect_language(req.message)
|
|
292
|
+
prompt_context = f"[LANGUAGE: {_LANG_HINT[language]}]\n" + (req.context or "")
|
|
540
293
|
context_trace = None
|
|
541
294
|
try:
|
|
542
|
-
if context_assembler is not None:
|
|
543
|
-
assembled = context_assembler.assemble(
|
|
295
|
+
if context.context_assembler is not None:
|
|
296
|
+
assembled = context.context_assembler.assemble(
|
|
544
297
|
req.message,
|
|
545
298
|
user_email=effective_email,
|
|
299
|
+
workspace_id=workspace_id,
|
|
546
300
|
conversation_id=req.conversation_id,
|
|
547
301
|
budget=2000,
|
|
548
302
|
)
|
|
549
303
|
context_trace = assembled.trace()
|
|
550
304
|
if assembled.text:
|
|
551
|
-
|
|
552
|
-
except Exception as
|
|
553
|
-
logging.warning("Context assembly skipped: %s",
|
|
554
|
-
|
|
555
|
-
is_doc_gen = detect_document_intent(req.message)
|
|
556
|
-
doc_gen_context_result = None
|
|
557
|
-
|
|
558
|
-
try:
|
|
559
|
-
if ENABLE_GRAPH and KNOWLEDGE_GRAPH and is_doc_gen:
|
|
560
|
-
# Specialized multi-hop retrieval for document generation.
|
|
561
|
-
doc_gen_context_result = retrieve_context_for_generation(
|
|
562
|
-
KNOWLEDGE_GRAPH, req.message, max_results=10, max_hops=2,
|
|
563
|
-
)
|
|
564
|
-
graph_md = doc_gen_context_result.get("context_markdown", "")
|
|
565
|
-
if graph_md:
|
|
566
|
-
context += f"\n\n[KNOWLEDGE GRAPH — Document Generation Context]\n{graph_md}"
|
|
567
|
-
logging.debug("Document generation context retrieved from knowledge graph.")
|
|
568
|
-
except Exception as e:
|
|
569
|
-
logging.warning("Knowledge graph reinforcement skipped: %s", e)
|
|
305
|
+
prompt_context += "\n\n" + assembled.text
|
|
306
|
+
except Exception as exc:
|
|
307
|
+
logging.warning("Context assembly skipped: %s", exc)
|
|
570
308
|
|
|
309
|
+
document_preparation = document_coordinator.prepare(
|
|
310
|
+
req,
|
|
311
|
+
prompt_context,
|
|
312
|
+
workspace_id=workspace_id,
|
|
313
|
+
)
|
|
314
|
+
prompt_context = document_preparation.context
|
|
571
315
|
if req.image_data:
|
|
572
316
|
screenshot_context = extract_screenshot_context(req.image_data)
|
|
573
317
|
if screenshot_context:
|
|
574
|
-
|
|
318
|
+
prompt_context += f"\n\n{screenshot_context}"
|
|
575
319
|
|
|
576
|
-
if
|
|
577
|
-
|
|
578
|
-
|
|
320
|
+
if getattr(config, "auto_read_chat_paths", False):
|
|
321
|
+
file_path_pattern = re.compile(
|
|
322
|
+
r'(?:^|[\s\'"(])((~|/[\w.])[^\s\'")\]]*)',
|
|
323
|
+
re.MULTILINE,
|
|
324
|
+
)
|
|
325
|
+
requested_paths = [
|
|
326
|
+
match.group(1).strip()
|
|
327
|
+
for match in file_path_pattern.finditer(req.message or "")
|
|
328
|
+
]
|
|
579
329
|
if requested_paths:
|
|
580
|
-
append_audit_event(
|
|
330
|
+
context.append_audit_event(
|
|
581
331
|
"auto_file_context_blocked",
|
|
582
332
|
user_email=effective_email,
|
|
583
333
|
path_count=len(requested_paths),
|
|
584
334
|
allow_file_context=req.allow_file_context,
|
|
585
|
-
reason=
|
|
335
|
+
reason=(
|
|
336
|
+
"local file context requires an explicit approved "
|
|
337
|
+
"file/tool flow"
|
|
338
|
+
),
|
|
586
339
|
)
|
|
587
340
|
if req.allow_file_context:
|
|
588
341
|
raise HTTPException(
|
|
589
342
|
status_code=400,
|
|
590
343
|
detail=(
|
|
591
344
|
"Automatic local file reads are disabled in chat. "
|
|
592
|
-
"Attach the file, upload it, or use an approved
|
|
345
|
+
"Attach the file, upload it, or use an approved "
|
|
346
|
+
"local-file tool flow."
|
|
593
347
|
),
|
|
594
348
|
)
|
|
595
349
|
|
|
596
|
-
trace_seed =
|
|
350
|
+
trace_seed = chat_service.build_graph_trace(
|
|
597
351
|
req.message,
|
|
598
|
-
|
|
599
|
-
context
|
|
352
|
+
context.knowledge_graph
|
|
353
|
+
if (context.enable_graph and context.knowledge_graph)
|
|
354
|
+
else None,
|
|
355
|
+
prompt_context,
|
|
356
|
+
allowed_workspaces={workspace_id} if workspace_id else None,
|
|
600
357
|
)
|
|
601
358
|
if context_trace is not None and isinstance(trace_seed, dict):
|
|
602
|
-
# Persisted with the answer trace: 'why is this in my context?'
|
|
603
|
-
# is answerable from the stored record (UI surface lands in T9b).
|
|
604
359
|
trace_seed["context_assembly"] = context_trace
|
|
605
360
|
|
|
606
|
-
history_message =
|
|
607
|
-
|
|
361
|
+
history_message = (
|
|
362
|
+
f"{req.message}\n[Image attached]" if req.image_data else req.message
|
|
363
|
+
)
|
|
364
|
+
await chat_service.persist_entry(
|
|
365
|
+
"user",
|
|
366
|
+
history_message,
|
|
367
|
+
history_meta=history_meta,
|
|
368
|
+
history_user=history_user,
|
|
369
|
+
)
|
|
608
370
|
notify_chat_message("user", req.message, req.source)
|
|
609
371
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
if req.stream:
|
|
622
|
-
async def _stream_doc_gen():
|
|
623
|
-
collected = []
|
|
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"
|
|
637
|
-
full_text = "".join(collected)
|
|
638
|
-
if footnote:
|
|
639
|
-
yield f"data: {json.dumps({'text': footnote}, ensure_ascii=False)}\n\n"
|
|
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}"
|
|
643
|
-
session.update(graph_md, full_text, req.conversation_id)
|
|
644
|
-
await asyncio.to_thread(save_to_history, "assistant", full_text, **history_meta, **history_user)
|
|
645
|
-
trace_record = CHAT_SERVICE.record_trace(
|
|
646
|
-
question=req.message,
|
|
647
|
-
response=full_text,
|
|
648
|
-
conversation_id=req.conversation_id,
|
|
649
|
-
user_email=effective_email,
|
|
650
|
-
trace=trace_seed,
|
|
651
|
-
)
|
|
652
|
-
notify_chat_message("assistant", full_text, req.source)
|
|
653
|
-
yield f"data: {json.dumps({'text': '', 'trace_id': trace_record['id'], 'trace': trace_record}, ensure_ascii=False)}\n\n"
|
|
654
|
-
yield "data: [DONE]\n\n"
|
|
655
|
-
return StreamingResponse(
|
|
656
|
-
_stream_doc_gen(),
|
|
657
|
-
media_type="text/event-stream",
|
|
658
|
-
headers={"X-Model": router.current_model_id, "X-Doc-Gen": "true"},
|
|
659
|
-
)
|
|
660
|
-
else:
|
|
661
|
-
result = await router.generate_document(
|
|
662
|
-
req.message, system_prompt,
|
|
663
|
-
max_tokens=req.max_tokens or 8192,
|
|
664
|
-
temperature=req.temperature or 0.3,
|
|
665
|
-
)
|
|
666
|
-
if footnote:
|
|
667
|
-
result += footnote
|
|
668
|
-
session.update(graph_md, result, req.conversation_id)
|
|
669
|
-
await asyncio.to_thread(save_to_history, "assistant", str(result), **history_meta, **history_user)
|
|
670
|
-
trace_record = CHAT_SERVICE.record_trace(
|
|
671
|
-
question=req.message,
|
|
672
|
-
response=str(result),
|
|
673
|
-
conversation_id=req.conversation_id,
|
|
674
|
-
user_email=effective_email,
|
|
675
|
-
trace=trace_seed,
|
|
676
|
-
)
|
|
677
|
-
notify_chat_message("assistant", str(result), req.source)
|
|
678
|
-
return JSONResponse(content={"response": str(result), "trace_id": trace_record["id"], "trace": trace_record})
|
|
372
|
+
document_response = await document_coordinator.response(
|
|
373
|
+
req,
|
|
374
|
+
document_preparation,
|
|
375
|
+
model_id=selected_model_id,
|
|
376
|
+
effective_email=effective_email,
|
|
377
|
+
workspace_id=workspace_id,
|
|
378
|
+
history_meta=history_meta,
|
|
379
|
+
trace_seed=trace_seed,
|
|
380
|
+
)
|
|
381
|
+
if document_response is not None:
|
|
382
|
+
return document_response
|
|
679
383
|
|
|
680
384
|
if req.stream:
|
|
681
|
-
recent_context = recent_chat_context(
|
|
682
|
-
|
|
385
|
+
recent_context = recent_chat_context(
|
|
386
|
+
user_email=effective_email,
|
|
387
|
+
conversation_id=req.conversation_id,
|
|
388
|
+
workspace_id=workspace_id,
|
|
389
|
+
)
|
|
390
|
+
stream_context = prompt_context
|
|
683
391
|
if recent_context:
|
|
684
|
-
stream_context =
|
|
392
|
+
stream_context = (
|
|
393
|
+
f"[RECENT CONVERSATION]\n{recent_context}\n\n{prompt_context}"
|
|
394
|
+
).strip()
|
|
685
395
|
return StreamingResponse(
|
|
686
|
-
|
|
396
|
+
stream_chat(
|
|
687
397
|
req,
|
|
688
398
|
stream_context,
|
|
689
399
|
req.image_data,
|
|
400
|
+
router=model_router,
|
|
401
|
+
chat_service=chat_service,
|
|
402
|
+
knowledge_graph=context.knowledge_graph,
|
|
403
|
+
enable_graph=context.enable_graph,
|
|
404
|
+
notify=notify_chat_message,
|
|
690
405
|
trace_seed=trace_seed,
|
|
691
406
|
effective_email=effective_email,
|
|
692
407
|
history_meta=history_meta,
|
|
408
|
+
model_id=selected_model_id,
|
|
409
|
+
workspace_id=workspace_id,
|
|
693
410
|
),
|
|
694
411
|
media_type="text/event-stream",
|
|
695
|
-
headers={"X-Model":
|
|
412
|
+
headers={"X-Model": selected_model_id},
|
|
696
413
|
)
|
|
697
|
-
else:
|
|
698
|
-
if req.image_data:
|
|
699
|
-
recent_context = recent_chat_context(
|
|
700
|
-
limit=6,
|
|
701
|
-
include_image_missing_replies=False,
|
|
702
|
-
user_email=effective_email,
|
|
703
|
-
conversation_id=req.conversation_id,
|
|
704
|
-
)
|
|
705
|
-
full_context = f"[RECENT CONVERSATION]\n{recent_context}\n\n{context}".strip() if recent_context else context
|
|
706
|
-
else:
|
|
707
|
-
history_context = recent_chat_context(user_email=effective_email, conversation_id=req.conversation_id)
|
|
708
|
-
full_context = f"{history_context}\n{context}" if context else history_context
|
|
709
|
-
|
|
710
|
-
result = await router.generate(req.message, full_context, req.max_tokens, req.temperature, req.image_data)
|
|
711
414
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
415
|
+
if req.image_data:
|
|
416
|
+
recent_context = recent_chat_context(
|
|
417
|
+
limit=6,
|
|
418
|
+
include_image_missing_replies=False,
|
|
419
|
+
user_email=effective_email,
|
|
716
420
|
conversation_id=req.conversation_id,
|
|
421
|
+
workspace_id=workspace_id,
|
|
422
|
+
)
|
|
423
|
+
full_context = (
|
|
424
|
+
f"[RECENT CONVERSATION]\n{recent_context}\n\n{prompt_context}".strip()
|
|
425
|
+
if recent_context
|
|
426
|
+
else prompt_context
|
|
427
|
+
)
|
|
428
|
+
else:
|
|
429
|
+
history_context = recent_chat_context(
|
|
717
430
|
user_email=effective_email,
|
|
718
|
-
|
|
431
|
+
conversation_id=req.conversation_id,
|
|
432
|
+
workspace_id=workspace_id,
|
|
719
433
|
)
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
async def fetch_history_conversations(request: Request):
|
|
733
|
-
"""저장된 히스토리를 대화 단위로 묶어 반환합니다."""
|
|
734
|
-
current_user = require_user(request)
|
|
735
|
-
return group_history_conversations(get_history(**history_scope_for_user(current_user)))
|
|
736
|
-
|
|
737
|
-
@api_router.get("/history/conversations/{conversation_id:path}")
|
|
738
|
-
async def fetch_history_conversation(conversation_id: str, request: Request):
|
|
739
|
-
"""선택한 대화의 메시지를 반환합니다."""
|
|
740
|
-
current_user = require_user(request)
|
|
741
|
-
messages = get_conversation_messages(conversation_id, **history_scope_for_user(current_user))
|
|
742
|
-
if not messages:
|
|
743
|
-
raise HTTPException(status_code=404, detail="대화를 찾을 수 없습니다.")
|
|
744
|
-
return {"id": conversation_id, "messages": messages}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
@api_router.delete("/history/conversations/{conversation_id:path}")
|
|
748
|
-
async def delete_history_conversation(conversation_id: str, request: Request):
|
|
749
|
-
"""선택한 대화방의 메시지만 삭제합니다."""
|
|
750
|
-
email = require_user(request)
|
|
751
|
-
result = clear_conversation(
|
|
752
|
-
conversation_id,
|
|
753
|
-
request.query_params.get("started_at"),
|
|
754
|
-
**history_scope_for_user(email),
|
|
755
|
-
)
|
|
756
|
-
append_audit_event(
|
|
757
|
-
"conversation_delete",
|
|
758
|
-
user_email=email,
|
|
759
|
-
conversation_id=conversation_id,
|
|
760
|
-
started_at=request.query_params.get("started_at"),
|
|
761
|
-
removed=result.get("removed", 0),
|
|
762
|
-
kept=result.get("kept", 0),
|
|
434
|
+
full_context = (
|
|
435
|
+
f"{history_context}\n{prompt_context}"
|
|
436
|
+
if prompt_context
|
|
437
|
+
else history_context
|
|
438
|
+
)
|
|
439
|
+
result = await model_router.generate_as(
|
|
440
|
+
selected_model_id,
|
|
441
|
+
req.message,
|
|
442
|
+
full_context,
|
|
443
|
+
req.max_tokens,
|
|
444
|
+
req.temperature,
|
|
445
|
+
req.image_data,
|
|
763
446
|
)
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
kept=result.get("kept", 0),
|
|
447
|
+
response_text = str(result)
|
|
448
|
+
trace_record = await chat_service.persist_answer(
|
|
449
|
+
question=req.message,
|
|
450
|
+
response=response_text,
|
|
451
|
+
conversation_id=req.conversation_id,
|
|
452
|
+
user_email=effective_email,
|
|
453
|
+
user_nickname=req.user_nickname,
|
|
454
|
+
source=req.source,
|
|
455
|
+
trace=trace_seed,
|
|
456
|
+
workspace_id=workspace_id,
|
|
457
|
+
history_meta=history_meta,
|
|
458
|
+
notify=notify_chat_message,
|
|
777
459
|
)
|
|
778
|
-
return
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
current_user = require_user(request)
|
|
784
|
-
if not q or not q.strip():
|
|
785
|
-
return {"results": [], "query": q}
|
|
786
|
-
q_lower = q.strip().lower()
|
|
787
|
-
history = get_history(**history_scope_for_user(current_user))
|
|
788
|
-
matches = [item for item in history if q_lower in (item.get("content") or "").lower()]
|
|
789
|
-
grouped: Dict[str, Dict] = {}
|
|
790
|
-
for item in matches:
|
|
791
|
-
cid = item.get("conversation_id") or "legacy"
|
|
792
|
-
if cid not in grouped:
|
|
793
|
-
grouped[cid] = {"conversation_id": cid, "title": conversation_title(item), "messages": []}
|
|
794
|
-
grouped[cid]["messages"].append(item)
|
|
795
|
-
return {"results": list(grouped.values())[-30:], "query": q}
|
|
796
|
-
|
|
797
|
-
async def _stream_chat(
|
|
798
|
-
req: ChatRequest,
|
|
799
|
-
context: str = "",
|
|
800
|
-
image_data: str = None,
|
|
801
|
-
*,
|
|
802
|
-
trace_seed: Optional[Dict] = None,
|
|
803
|
-
effective_email: Optional[str] = None,
|
|
804
|
-
history_meta: Optional[Dict] = None,
|
|
805
|
-
) -> AsyncIterator[str]:
|
|
806
|
-
full_response = ""
|
|
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"
|
|
817
|
-
history_user = get_history_user(effective_email or req.user_email, req.user_nickname)
|
|
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"
|
|
846
|
-
yield "data: [DONE]\n\n"
|
|
847
|
-
|
|
848
|
-
@api_router.post("/agent/eval")
|
|
849
|
-
async def agent_eval(req: AgentEvalRequest, request: Request):
|
|
850
|
-
"""Run a skill's eval cases from schema.json and return pass/fail per case."""
|
|
851
|
-
require_user(request)
|
|
852
|
-
skill_dir = BASE_DIR / "skills" / req.skill
|
|
853
|
-
schema_path = skill_dir / "schema.json"
|
|
854
|
-
if not schema_path.exists():
|
|
855
|
-
raise HTTPException(404, detail=f"Skill '{req.skill}' not found or missing schema.json")
|
|
856
|
-
|
|
857
|
-
schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
858
|
-
eval_cases = schema.get("evals", [])
|
|
859
|
-
if req.case_id:
|
|
860
|
-
eval_cases = [c for c in eval_cases if c.get("id") == req.case_id]
|
|
861
|
-
if not eval_cases:
|
|
862
|
-
return {"skill": req.skill, "total": 0, "passed": 0, "failed": 0, "results": [],
|
|
863
|
-
"message": "No eval cases defined in schema.json"}
|
|
864
|
-
|
|
865
|
-
action_name = schema.get("action", req.skill)
|
|
866
|
-
results = []
|
|
867
|
-
for case in eval_cases:
|
|
868
|
-
case_id = case.get("id", "?")
|
|
869
|
-
try:
|
|
870
|
-
case_input = case.get("input", {})
|
|
871
|
-
result = dispatch_tool(hooks, action_name, case_input,
|
|
872
|
-
lambda: execute_tool(action_name, case_input), source="eval")
|
|
873
|
-
criteria = case.get("pass_criteria", "")
|
|
874
|
-
if "success == true" in criteria:
|
|
875
|
-
passed = result.get("success") is True
|
|
876
|
-
elif "success == false" in criteria:
|
|
877
|
-
passed = result.get("success") is False
|
|
878
|
-
else:
|
|
879
|
-
passed = True # manual review required
|
|
880
|
-
results.append({"id": case_id, "description": case.get("description", ""),
|
|
881
|
-
"passed": passed, "result": result, "pass_criteria": criteria})
|
|
882
|
-
except Exception as exc:
|
|
883
|
-
results.append({"id": case_id, "description": case.get("description", ""),
|
|
884
|
-
"passed": False, "error": str(exc),
|
|
885
|
-
"pass_criteria": case.get("pass_criteria", "")})
|
|
886
|
-
|
|
887
|
-
n_passed = sum(1 for r in results if r.get("passed") is True)
|
|
888
|
-
return {
|
|
889
|
-
"skill": req.skill, "action": action_name,
|
|
890
|
-
"total": len(results), "passed": n_passed, "failed": len(results) - n_passed,
|
|
891
|
-
"results": results,
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
@api_router.post("/agent")
|
|
896
|
-
async def agent(req: AgentRequest, request: Request):
|
|
897
|
-
"""Natural-language local agent.
|
|
898
|
-
|
|
899
|
-
State machine:
|
|
900
|
-
IDLE → PLANNING → WAITING_APPROVAL → EXECUTING → VERIFYING
|
|
901
|
-
↓ ↓
|
|
902
|
-
FAILED DONE | EXECUTING(retry) | ROLLBACK
|
|
903
|
-
↓
|
|
904
|
-
FAILED
|
|
905
|
-
"""
|
|
906
|
-
current_user = require_user(request)
|
|
907
|
-
enforce_rate_limit(current_user, "agent")
|
|
908
|
-
req.workspace_id = req.workspace_id or workspace_scope_from_request(request)
|
|
909
|
-
if not router.current_model_id:
|
|
910
|
-
raise HTTPException(status_code=400, detail="No model loaded. Call /models/load first.")
|
|
911
|
-
|
|
912
|
-
ensure_agent_root()
|
|
913
|
-
lang = detect_language(req.message)
|
|
914
|
-
lang_hint = _LANG_HINT[lang]
|
|
915
|
-
max_steps = max(1, min(req.max_steps, 50))
|
|
916
|
-
max_retry = 3
|
|
917
|
-
|
|
918
|
-
ctx = AgentRunContext()
|
|
919
|
-
ctx.executing_model = req.executing_model
|
|
920
|
-
ctx.reviewing_model = req.reviewing_model
|
|
921
|
-
|
|
922
|
-
# PLANNING phase
|
|
923
|
-
ctx.state = AgentState.PLANNING
|
|
924
|
-
ctx.state_history.append(ctx.state.value)
|
|
925
|
-
await _AGENT_RUNTIME.plan(ctx, req, lang_hint, current_user, model_id=req.planning_model)
|
|
926
|
-
|
|
927
|
-
# Human-in-the-loop: pause after planning, return plan to UI
|
|
928
|
-
if req.human_in_loop:
|
|
929
|
-
context_id = secrets.token_urlsafe(16)
|
|
930
|
-
with _pending_agents_lock:
|
|
931
|
-
_pending_agents[context_id] = (ctx, req, lang_hint, current_user)
|
|
932
|
-
return {
|
|
933
|
-
"status": "waiting_approval",
|
|
934
|
-
"context_id": context_id,
|
|
935
|
-
"plan": ctx.plan,
|
|
936
|
-
"steps": ctx.transcript,
|
|
937
|
-
"state_history": ctx.state_history,
|
|
938
|
-
"planning_model": req.planning_model or router.current_model_id,
|
|
939
|
-
"executing_model": req.executing_model or router.current_model_id,
|
|
940
|
-
"reviewing_model": req.reviewing_model or router.current_model_id,
|
|
460
|
+
return JSONResponse(
|
|
461
|
+
content={
|
|
462
|
+
"response": response_text,
|
|
463
|
+
"trace_id": trace_record["id"],
|
|
464
|
+
"trace": trace_record,
|
|
941
465
|
}
|
|
466
|
+
)
|
|
942
467
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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)
|
|
959
|
-
try:
|
|
960
|
-
WORKSPACE_OS.record_agent_run(
|
|
961
|
-
agent_id="agent:executor",
|
|
962
|
-
status="ok" if ctx.state == AgentState.DONE else "failed",
|
|
963
|
-
input_text=req.message,
|
|
964
|
-
output_text=message,
|
|
965
|
-
user_email=current_user or None,
|
|
966
|
-
timeline=ctx.transcript,
|
|
967
|
-
relationships=["agent:planner", "agent:reviewer"],
|
|
968
|
-
graph=workspace_graph(),
|
|
969
|
-
)
|
|
970
|
-
except Exception as exc:
|
|
971
|
-
logging.warning("workspace agent run record failed: %s", exc)
|
|
972
|
-
created_files = collect_created_files(ctx.transcript)
|
|
973
|
-
return {
|
|
974
|
-
"status": "ok" if ctx.state == AgentState.DONE else "failed",
|
|
975
|
-
"response": message,
|
|
976
|
-
"workspace": str(AGENT_ROOT),
|
|
977
|
-
"steps": ctx.transcript,
|
|
978
|
-
"state_history": ctx.state_history,
|
|
979
|
-
"final_state": ctx.state.value,
|
|
980
|
-
"created_files": created_files,
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
@api_router.post("/agent/resume")
|
|
985
|
-
async def agent_resume(req: AgentResumeRequest, request: Request):
|
|
986
|
-
"""Resume a paused agent after human approval of the plan."""
|
|
987
|
-
current_user = require_user(request)
|
|
988
|
-
|
|
989
|
-
with _pending_agents_lock:
|
|
990
|
-
entry = _pending_agents.pop(req.context_id, None)
|
|
991
|
-
if not entry:
|
|
992
|
-
raise HTTPException(status_code=404, detail="Agent context not found or expired. Start a new request.")
|
|
993
|
-
|
|
994
|
-
ctx, orig_req, lang_hint, _orig_user = entry
|
|
995
|
-
|
|
996
|
-
if not req.approved:
|
|
997
|
-
return {"status": "cancelled", "response": "사용자가 계획을 취소했습니다."}
|
|
998
|
-
|
|
999
|
-
if req.modified_plan:
|
|
1000
|
-
ctx.plan = req.modified_plan
|
|
1001
|
-
ctx.transcript[-1].update(ctx.plan) # keep transcript in sync
|
|
1002
|
-
|
|
1003
|
-
# Apply model overrides from resume request (takes priority over original request)
|
|
1004
|
-
ctx.executing_model = req.executing_model or ctx.executing_model
|
|
1005
|
-
ctx.reviewing_model = req.reviewing_model or ctx.reviewing_model
|
|
1006
|
-
|
|
1007
|
-
_AGENT_RUNTIME.approve(ctx, current_user)
|
|
1008
|
-
|
|
1009
|
-
max_steps = max(1, min(orig_req.max_steps, 50))
|
|
1010
|
-
max_retry = 3
|
|
1011
|
-
return await _agent_finish(ctx, orig_req, lang_hint, current_user, max_steps, max_retry)
|
|
1012
|
-
|
|
468
|
+
register_history_routes(
|
|
469
|
+
api_router,
|
|
470
|
+
HistoryRouteDependencies(
|
|
471
|
+
chat_service=chat_service,
|
|
472
|
+
require_user=require_user,
|
|
473
|
+
scope_for_user=history_scope_for_user,
|
|
474
|
+
group_conversations=context.group_history_conversations,
|
|
475
|
+
get_conversation_messages=context.get_conversation_messages,
|
|
476
|
+
conversation_title=context.conversation_title,
|
|
477
|
+
clear_conversation=context.clear_conversation,
|
|
478
|
+
clear_history=context.clear_history,
|
|
479
|
+
append_audit_event=context.append_audit_event,
|
|
480
|
+
),
|
|
481
|
+
)
|
|
482
|
+
agent_controller.register_routes(api_router)
|
|
1013
483
|
return api_router
|