feat(agents): serve the agent API and bridge to the sandbox runtime
Add agents/ as a first-class platform resource (collections, agents, avatars, chat, builder) served through the API host, plus sandbox-rpc, the gRPC client the platform uses to talk to a standalone sandbox-agent runtime: create sessions and runs, stream events when a fanout endpoint is configured, and fall back to polling when it is not. The console gets the matching agent workspace, and a global /sandbox dev proxy keeps the event stream same-origin.
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Added lib/agents/src/handlers/agents.rs lib/agents/src/handlers/agents.rs +142 −0 View lib/agents/src/handlers/agents.rs
@@ -0,0 +1,142 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session_layer::Session;
4use session_layer::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::agents::{
8 AgentDto, AgentPage, AgentPath, CreateAgentCmd, ListAgentQuery, UpdateAgentCmd,
9};
10use crate::services::service::AgentsService;
11
12#[utoipa::path(
13 post,
14 path = "/agents/{space_name}/collections/{collection_id}/agents",
15 tag = "agents",
16 description = "在智能体集内创建智能体,并创建其运行时长期会话;服务商与模型由平台默认提供。",
17 params(crate::services::collections::AgentCollectionPath),
18 request_body = CreateAgentCmd,
19 responses(
20 (status = 200, description = "agent created", body = AppResponseData<AgentDto>),
21 (status = 400, description = "bad request", body = AppResponseData<NoData>),
22 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
23 (status = 404, description = "not found", body = AppResponseData<NoData>),
24 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
25 )
26)]
27pub async fn create_agent(
28 State(service): State<AgentsService>,
29 session: Session,
30 Path(path): Path<crate::services::collections::AgentCollectionPath>,
31 Json(cmd): Json<CreateAgentCmd>,
32) -> Result<AppResponseData<AgentDto>, ApiError> {
33 let uid = require_uid(&session)?;
34 let dto = service
35 .create_agent(uid, &path.space_name, path.collection_id, cmd)
36 .await?;
37 Ok(AppResponseData::ok(dto))
38}
39
40#[utoipa::path(
41 get,
42 path = "/agents/{space_name}/collections/{collection_id}/agents",
43 tag = "agents",
44 description = "列出智能体集内的智能体。",
45 params(crate::services::collections::AgentCollectionPath, ListAgentQuery),
46 responses(
47 (status = 200, description = "agent page", body = AppResponseData<AgentPage>),
48 (status = 404, description = "not found", body = AppResponseData<NoData>)
49 )
50)]
51pub async fn list_agents(
52 State(service): State<AgentsService>,
53 session: Session,
54 Path(path): Path<crate::services::collections::AgentCollectionPath>,
55 Query(query): Query<ListAgentQuery>,
56) -> Result<AppResponseData<AgentPage>, ApiError> {
57 let uid = require_uid(&session)?;
58 let page = service
59 .list_agents(uid, &path.space_name, path.collection_id, query)
60 .await?;
61 Ok(AppResponseData::ok(page))
62}
63
64#[utoipa::path(
65 get,
66 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}",
67 tag = "agents",
68 description = "读取单个智能体。",
69 params(AgentPath),
70 responses(
71 (status = 200, description = "agent", body = AppResponseData<AgentDto>),
72 (status = 404, description = "not found", body = AppResponseData<NoData>)
73 )
74)]
75pub async fn get_agent(
76 State(service): State<AgentsService>,
77 session: Session,
78 Path(path): Path<AgentPath>,
79) -> Result<AppResponseData<AgentDto>, ApiError> {
80 let uid = require_uid(&session)?;
81 let dto = service
82 .get_agent(uid, &path.space_name, path.collection_id, path.agent_id)
83 .await?;
84 Ok(AppResponseData::ok(dto))
85}
86
87#[utoipa::path(
88 patch,
89 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}",
90 tag = "agents",
91 description = "更新智能体名称或描述;instructions 在会话创建后不可变,服务商与模型由平台默认提供。",
92 params(AgentPath),
93 request_body = UpdateAgentCmd,
94 responses(
95 (status = 200, description = "agent updated", body = AppResponseData<AgentDto>),
96 (status = 400, description = "bad request", body = AppResponseData<NoData>),
97 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
98 (status = 404, description = "not found", body = AppResponseData<NoData>)
99 )
100)]
101pub async fn update_agent(
102 State(service): State<AgentsService>,
103 session: Session,
104 Path(path): Path<AgentPath>,
105 Json(cmd): Json<UpdateAgentCmd>,
106) -> Result<AppResponseData<AgentDto>, ApiError> {
107 let uid = require_uid(&session)?;
108 let dto = service
109 .update_agent(
110 uid,
111 &path.space_name,
112 path.collection_id,
113 path.agent_id,
114 cmd,
115 )
116 .await?;
117 Ok(AppResponseData::ok(dto))
118}
119
120#[utoipa::path(
121 delete,
122 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}",
123 tag = "agents",
124 description = "删除智能体,并尝试清理运行时长期会话。",
125 params(AgentPath),
126 responses(
127 (status = 200, description = "agent deleted", body = AppResponseData<NoData>),
128 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
129 (status = 404, description = "not found", body = AppResponseData<NoData>)
130 )
131)]
132pub async fn delete_agent(
133 State(service): State<AgentsService>,
134 session: Session,
135 Path(path): Path<AgentPath>,
136) -> Result<AppResponseData<NoData>, ApiError> {
137 let uid = require_uid(&session)?;
138 service
139 .delete_agent(uid, &path.space_name, path.collection_id, path.agent_id)
140 .await?;
141 Ok(AppResponseData::ok(NoData))
142}
Added lib/agents/src/handlers/avatar.rs lib/agents/src/handlers/avatar.rs +79 −0 View lib/agents/src/handlers/avatar.rs
@@ -0,0 +1,79 @@
1use axum::body::Bytes;
2use axum::extract::{Path, State};
3use axum::http::HeaderMap;
4use axum::http::header::CONTENT_TYPE;
5use axum::response::Redirect;
6use export::rest::{AppResponseData, NoData};
7use session_layer::Session;
8use session_layer::share::session::require_uid;
9
10use crate::errors::ApiError;
11use crate::services::agents::{AgentDto, AgentPath};
12use crate::services::service::AgentsService;
13
14#[utoipa::path(
15 put,
16 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/avatar",
17 tag = "agents",
18 description = "上传原始图片字节作为智能体头像(png/jpeg/webp/gif,不超过 2 MB)。",
19 params(AgentPath),
20 request_body(
21 content = Vec<u8>,
22 description = "raw image bytes (image/png, image/jpeg, image/webp, image/gif)",
23 content_type = "image/png"
24 ),
25 responses(
26 (status = 200, description = "avatar uploaded", body = AppResponseData<AgentDto>),
27 (status = 400, description = "bad request", body = AppResponseData<NoData>),
28 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
29 (status = 404, description = "agent not found", body = AppResponseData<NoData>)
30 )
31)]
32pub async fn upload_agent_avatar(
33 State(service): State<AgentsService>,
34 session: Session,
35 Path(path): Path<AgentPath>,
36 headers: HeaderMap,
37 body: Bytes,
38) -> Result<AppResponseData<AgentDto>, ApiError> {
39 let uid = require_uid(&session)?;
40 let content_type = headers
41 .get(CONTENT_TYPE)
42 .and_then(|value| value.to_str().ok())
43 .unwrap_or("application/octet-stream")
44 .to_string();
45 let agent = service
46 .upload_agent_avatar(
47 uid,
48 &path.space_name,
49 path.collection_id,
50 path.agent_id,
51 &content_type,
52 &body,
53 )
54 .await?;
55 Ok(AppResponseData::ok(agent))
56}
57
58#[utoipa::path(
59 get,
60 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/avatar",
61 tag = "agents",
62 description = "307 重定向到智能体头像的临时下载地址。",
63 params(AgentPath),
64 responses(
65 (status = 307, description = "redirect to the presigned avatar url"),
66 (status = 404, description = "agent has no avatar", body = AppResponseData<NoData>)
67 )
68)]
69pub async fn agent_avatar(
70 State(service): State<AgentsService>,
71 session: Session,
72 Path(path): Path<AgentPath>,
73) -> Result<Redirect, ApiError> {
74 let uid = require_uid(&session)?;
75 let url = service
76 .agent_avatar_url(uid, &path.space_name, path.collection_id, path.agent_id)
77 .await?;
78 Ok(Redirect::temporary(&url))
79}
No newline at end of file
Added lib/agents/src/handlers/builder.rs lib/agents/src/handlers/builder.rs +36 −0 View lib/agents/src/handlers/builder.rs
@@ -0,0 +1,36 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session_layer::Session;
4use session_layer::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::builder::{BuilderChatCmd, BuilderChatResult};
8use crate::services::collections::AgentCollectionPath;
9use crate::services::service::AgentsService;
10
11#[utoipa::path(
12 post,
13 path = "/agents/{space_name}/collections/{collection_id}/builder",
14 tag = "agents",
15 description = "智能体构建器对话:结合构建对话返回回复与更新后的智能体草稿。",
16 params(AgentCollectionPath),
17 request_body = BuilderChatCmd,
18 responses(
19 (status = 200, description = "builder reply and draft", body = AppResponseData<BuilderChatResult>),
20 (status = 400, description = "bad request", body = AppResponseData<NoData>),
21 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
22 (status = 503, description = "ai unavailable", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn builder_chat(
26 State(service): State<AgentsService>,
27 session: Session,
28 Path(path): Path<AgentCollectionPath>,
29 Json(cmd): Json<BuilderChatCmd>,
30) -> Result<AppResponseData<BuilderChatResult>, ApiError> {
31 let uid = require_uid(&session)?;
32 let result = service
33 .builder_chat(uid, &path.space_name, path.collection_id, cmd)
34 .await?;
35 Ok(AppResponseData::ok(result))
36}
No newline at end of file
Added lib/agents/src/handlers/chat.rs lib/agents/src/handlers/chat.rs +303 −0 View lib/agents/src/handlers/chat.rs
@@ -0,0 +1,303 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session_layer::Session;
4use session_layer::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::agents::AgentPath;
8use crate::services::chat::{
9 AgentApprovalDto, AgentApprovalPath, AgentMessageDto, AgentQuestionDto, AgentQuestionPath,
10 AgentRunDto, AgentRunPath, AgentStreamTokenDto, AgentToolCallDto, AnswerAgentQuestionCmd,
11 DecideAgentApprovalCmd, ListAgentMessageQuery, ListAgentToolCallQuery, SendAgentMessageCmd,
12};
13use crate::services::service::AgentsService;
14
15#[utoipa::path(
16 post,
17 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/stream-token",
18 tag = "agents",
19 description = "签发浏览器直连 sandbox-agent fanout SSE 的短期流令牌(含 fanout 地址与会话 id)。",
20 params(AgentPath),
21 responses(
22 (status = 200, description = "stream token", body = AppResponseData<AgentStreamTokenDto>),
23 (status = 400, description = "bad request", body = AppResponseData<NoData>),
24 (status = 404, description = "not found", body = AppResponseData<NoData>),
25 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
26 )
27)]
28pub async fn create_agent_stream_token(
29 State(service): State<AgentsService>,
30 session: Session,
31 Path(path): Path<AgentPath>,
32) -> Result<AppResponseData<AgentStreamTokenDto>, ApiError> {
33 let uid = require_uid(&session)?;
34 let grant = service
35 .create_agent_stream_token(uid, &path.space_name, path.collection_id, path.agent_id)
36 .await?;
37 Ok(AppResponseData::ok(grant))
38}
39
40#[utoipa::path(
41 post,
42 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/messages",
43 tag = "agents",
44 description = "向智能体长期会话发送消息并启动一次运行(经 sandbox-agent RPC)。",
45 params(AgentPath),
46 request_body = SendAgentMessageCmd,
47 responses(
48 (status = 200, description = "run started", body = AppResponseData<AgentRunDto>),
49 (status = 400, description = "bad request", body = AppResponseData<NoData>),
50 (status = 404, description = "not found", body = AppResponseData<NoData>),
51 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
52 )
53)]
54pub async fn send_agent_message(
55 State(service): State<AgentsService>,
56 session: Session,
57 Path(path): Path<AgentPath>,
58 Json(cmd): Json<SendAgentMessageCmd>,
59) -> Result<AppResponseData<AgentRunDto>, ApiError> {
60 let uid = require_uid(&session)?;
61 let run = service
62 .send_agent_message(
63 uid,
64 &path.space_name,
65 path.collection_id,
66 path.agent_id,
67 cmd,
68 )
69 .await?;
70 Ok(AppResponseData::ok(run))
71}
72
73#[utoipa::path(
74 get,
75 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/messages",
76 tag = "agents",
77 description = "按序号增量拉取智能体会话消息。",
78 params(AgentPath, ListAgentMessageQuery),
79 responses(
80 (status = 200, description = "messages", body = AppResponseData<Vec<AgentMessageDto>>),
81 (status = 404, description = "not found", body = AppResponseData<NoData>),
82 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
83 )
84)]
85pub async fn list_agent_messages(
86 State(service): State<AgentsService>,
87 session: Session,
88 Path(path): Path<AgentPath>,
89 Query(query): Query<ListAgentMessageQuery>,
90) -> Result<AppResponseData<Vec<AgentMessageDto>>, ApiError> {
91 let uid = require_uid(&session)?;
92 let messages = service
93 .list_agent_messages(
94 uid,
95 &path.space_name,
96 path.collection_id,
97 path.agent_id,
98 query,
99 )
100 .await?;
101 Ok(AppResponseData::ok(messages))
102}
103
104#[utoipa::path(
105 get,
106 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/tool-calls",
107 tag = "agents",
108 description = "列出智能体会话的工具调用(名称、输入、耗时与状态),供消息渲染回填命令。",
109 params(AgentPath, ListAgentToolCallQuery),
110 responses(
111 (status = 200, description = "tool calls", body = AppResponseData<Vec<AgentToolCallDto>>),
112 (status = 404, description = "not found", body = AppResponseData<NoData>),
113 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
114 )
115)]
116pub async fn list_agent_tool_calls(
117 State(service): State<AgentsService>,
118 session: Session,
119 Path(path): Path<AgentPath>,
120 Query(query): Query<ListAgentToolCallQuery>,
121) -> Result<AppResponseData<Vec<AgentToolCallDto>>, ApiError> {
122 let uid = require_uid(&session)?;
123 let calls = service
124 .list_agent_tool_calls(
125 uid,
126 &path.space_name,
127 path.collection_id,
128 path.agent_id,
129 query,
130 )
131 .await?;
132 Ok(AppResponseData::ok(calls))
133}
134
135#[utoipa::path(
136 get,
137 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs",
138 tag = "agents",
139 description = "列出智能体会话的运行记录。",
140 params(AgentPath),
141 responses(
142 (status = 200, description = "runs", body = AppResponseData<Vec<AgentRunDto>>),
143 (status = 404, description = "not found", body = AppResponseData<NoData>),
144 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
145 )
146)]
147pub async fn list_agent_runs(
148 State(service): State<AgentsService>,
149 session: Session,
150 Path(path): Path<AgentPath>,
151) -> Result<AppResponseData<Vec<AgentRunDto>>, ApiError> {
152 let uid = require_uid(&session)?;
153 let runs = service
154 .list_agent_runs(uid, &path.space_name, path.collection_id, path.agent_id)
155 .await?;
156 Ok(AppResponseData::ok(runs))
157}
158
159#[utoipa::path(
160 post,
161 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs/{run_id}/cancel",
162 tag = "agents",
163 description = "取消一次运行。",
164 params(AgentRunPath),
165 responses(
166 (status = 200, description = "run canceled", body = AppResponseData<AgentRunDto>),
167 (status = 404, description = "not found", body = AppResponseData<NoData>),
168 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
169 )
170)]
171pub async fn cancel_agent_run(
172 State(service): State<AgentsService>,
173 session: Session,
174 Path(path): Path<AgentRunPath>,
175) -> Result<AppResponseData<AgentRunDto>, ApiError> {
176 let uid = require_uid(&session)?;
177 let run = service
178 .cancel_agent_run(
179 uid,
180 &path.space_name,
181 path.collection_id,
182 path.agent_id,
183 &path.run_id,
184 )
185 .await?;
186 Ok(AppResponseData::ok(run))
187}
188
189#[utoipa::path(
190 get,
191 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals",
192 tag = "agents",
193 description = "列出智能体会话的审批请求。",
194 params(AgentPath),
195 responses(
196 (status = 200, description = "approvals", body = AppResponseData<Vec<AgentApprovalDto>>),
197 (status = 404, description = "not found", body = AppResponseData<NoData>),
198 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
199 )
200)]
201pub async fn list_agent_approvals(
202 State(service): State<AgentsService>,
203 session: Session,
204 Path(path): Path<AgentPath>,
205) -> Result<AppResponseData<Vec<AgentApprovalDto>>, ApiError> {
206 let uid = require_uid(&session)?;
207 let approvals = service
208 .list_agent_approvals(uid, &path.space_name, path.collection_id, path.agent_id)
209 .await?;
210 Ok(AppResponseData::ok(approvals))
211}
212
213#[utoipa::path(
214 post,
215 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals/{approval_id}/decide",
216 tag = "agents",
217 description = "批准或拒绝一次工具调用审批。",
218 params(AgentApprovalPath),
219 request_body = DecideAgentApprovalCmd,
220 responses(
221 (status = 200, description = "approval decided", body = AppResponseData<AgentApprovalDto>),
222 (status = 400, description = "bad request", body = AppResponseData<NoData>),
223 (status = 404, description = "not found", body = AppResponseData<NoData>),
224 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
225 )
226)]
227pub async fn decide_agent_approval(
228 State(service): State<AgentsService>,
229 session: Session,
230 Path(path): Path<AgentApprovalPath>,
231 Json(cmd): Json<DecideAgentApprovalCmd>,
232) -> Result<AppResponseData<AgentApprovalDto>, ApiError> {
233 let uid = require_uid(&session)?;
234 let approval = service
235 .decide_agent_approval(
236 uid,
237 &path.space_name,
238 path.collection_id,
239 path.agent_id,
240 &path.approval_id,
241 cmd,
242 )
243 .await?;
244 Ok(AppResponseData::ok(approval))
245}
246
247#[utoipa::path(
248 get,
249 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions",
250 tag = "agents",
251 description = "列出智能体会话的提问请求。",
252 params(AgentPath),
253 responses(
254 (status = 200, description = "questions", body = AppResponseData<Vec<AgentQuestionDto>>),
255 (status = 404, description = "not found", body = AppResponseData<NoData>),
256 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
257 )
258)]
259pub async fn list_agent_questions(
260 State(service): State<AgentsService>,
261 session: Session,
262 Path(path): Path<AgentPath>,
263) -> Result<AppResponseData<Vec<AgentQuestionDto>>, ApiError> {
264 let uid = require_uid(&session)?;
265 let questions = service
266 .list_agent_questions(uid, &path.space_name, path.collection_id, path.agent_id)
267 .await?;
268 Ok(AppResponseData::ok(questions))
269}
270
271#[utoipa::path(
272 post,
273 path = "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions/{question_id}/answer",
274 tag = "agents",
275 description = "回答智能体提出的问题。",
276 params(AgentQuestionPath),
277 request_body = AnswerAgentQuestionCmd,
278 responses(
279 (status = 200, description = "question answered", body = AppResponseData<AgentQuestionDto>),
280 (status = 400, description = "bad request", body = AppResponseData<NoData>),
281 (status = 404, description = "not found", body = AppResponseData<NoData>),
282 (status = 503, description = "agent runtime unavailable", body = AppResponseData<NoData>)
283 )
284)]
285pub async fn answer_agent_questions(
286 State(service): State<AgentsService>,
287 session: Session,
288 Path(path): Path<AgentQuestionPath>,
289 Json(cmd): Json<AnswerAgentQuestionCmd>,
290) -> Result<AppResponseData<AgentQuestionDto>, ApiError> {
291 let uid = require_uid(&session)?;
292 let question = service
293 .answer_agent_questions(
294 uid,
295 &path.space_name,
296 path.collection_id,
297 path.agent_id,
298 &path.question_id,
299 cmd,
300 )
301 .await?;
302 Ok(AppResponseData::ok(question))
303}
Added lib/agents/src/handlers/collections.rs lib/agents/src/handlers/collections.rs +141 −0 View lib/agents/src/handlers/collections.rs
@@ -0,0 +1,141 @@
1use axum::extract::{Json, Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session_layer::Session;
4use session_layer::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::collections::{
8 AgentCollectionDto, AgentCollectionPage, AgentCollectionPath, CreateAgentCollectionCmd,
9 ListAgentCollectionQuery, UpdateAgentCollectionCmd,
10};
11use crate::services::service::AgentsService;
12use space::services::spaces::SpaceNamePath;
13
14#[utoipa::path(
15 post,
16 path = "/agents/{space_name}/collections",
17 tag = "agents",
18 description = "在空间内创建智能体集,并同步创建其目录树节点。",
19 params(SpaceNamePath),
20 request_body = CreateAgentCollectionCmd,
21 responses(
22 (status = 200, description = "collection created", body = AppResponseData<AgentCollectionDto>),
23 (status = 400, description = "bad request", body = AppResponseData<NoData>),
24 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
25 (status = 404, description = "not found", body = AppResponseData<NoData>),
26 (status = 409, description = "resource already exists", body = AppResponseData<NoData>),
27 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
28 )
29)]
30pub async fn create_agent_collection(
31 State(service): State<AgentsService>,
32 session: Session,
33 Path(path): Path<SpaceNamePath>,
34 Json(cmd): Json<CreateAgentCollectionCmd>,
35) -> Result<AppResponseData<AgentCollectionDto>, ApiError> {
36 let uid = require_uid(&session)?;
37 let dto = service
38 .create_agent_collection(uid, &path.space_name, cmd)
39 .await?;
40 Ok(AppResponseData::ok(dto))
41}
42
43#[utoipa::path(
44 get,
45 path = "/agents/{space_name}/collections",
46 tag = "agents",
47 description = "列出空间内的智能体集。",
48 params(SpaceNamePath, ListAgentCollectionQuery),
49 responses(
50 (status = 200, description = "collection page", body = AppResponseData<AgentCollectionPage>),
51 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
52 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
53 )
54)]
55pub async fn list_agent_collections(
56 State(service): State<AgentsService>,
57 session: Session,
58 Path(path): Path<SpaceNamePath>,
59 Query(query): Query<ListAgentCollectionQuery>,
60) -> Result<AppResponseData<AgentCollectionPage>, ApiError> {
61 let uid = require_uid(&session)?;
62 let page = service
63 .list_agent_collections(uid, &path.space_name, query)
64 .await?;
65 Ok(AppResponseData::ok(page))
66}
67
68#[utoipa::path(
69 get,
70 path = "/agents/{space_name}/collections/{collection_id}",
71 tag = "agents",
72 description = "读取单个智能体集。",
73 params(AgentCollectionPath),
74 responses(
75 (status = 200, description = "collection", body = AppResponseData<AgentCollectionDto>),
76 (status = 404, description = "not found", body = AppResponseData<NoData>),
77 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
78 )
79)]
80pub async fn get_agent_collection(
81 State(service): State<AgentsService>,
82 session: Session,
83 Path(path): Path<AgentCollectionPath>,
84) -> Result<AppResponseData<AgentCollectionDto>, ApiError> {
85 let uid = require_uid(&session)?;
86 let dto = service
87 .get_agent_collection(uid, &path.space_name, path.collection_id)
88 .await?;
89 Ok(AppResponseData::ok(dto))
90}
91
92#[utoipa::path(
93 patch,
94 path = "/agents/{space_name}/collections/{collection_id}",
95 tag = "agents",
96 description = "更新智能体集名称、图标或描述。",
97 params(AgentCollectionPath),
98 request_body = UpdateAgentCollectionCmd,
99 responses(
100 (status = 200, description = "collection updated", body = AppResponseData<AgentCollectionDto>),
101 (status = 400, description = "bad request", body = AppResponseData<NoData>),
102 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
103 (status = 404, description = "not found", body = AppResponseData<NoData>)
104 )
105)]
106pub async fn update_agent_collection(
107 State(service): State<AgentsService>,
108 session: Session,
109 Path(path): Path<AgentCollectionPath>,
110 Json(cmd): Json<UpdateAgentCollectionCmd>,
111) -> Result<AppResponseData<AgentCollectionDto>, ApiError> {
112 let uid = require_uid(&session)?;
113 let dto = service
114 .update_agent_collection(uid, &path.space_name, path.collection_id, cmd)
115 .await?;
116 Ok(AppResponseData::ok(dto))
117}
118
119#[utoipa::path(
120 delete,
121 path = "/agents/{space_name}/collections/{collection_id}",
122 tag = "agents",
123 description = "删除智能体集及其下全部智能体,并尝试清理运行时会话。",
124 params(AgentCollectionPath),
125 responses(
126 (status = 200, description = "collection deleted", body = AppResponseData<NoData>),
127 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
128 (status = 404, description = "not found", body = AppResponseData<NoData>)
129 )
130)]
131pub async fn delete_agent_collection(
132 State(service): State<AgentsService>,
133 session: Session,
134 Path(path): Path<AgentCollectionPath>,
135) -> Result<AppResponseData<NoData>, ApiError> {
136 let uid = require_uid(&session)?;
137 service
138 .delete_agent_collection(uid, &path.space_name, path.collection_id)
139 .await?;
140 Ok(AppResponseData::ok(NoData))
141}
Added lib/agents/src/handlers/mod.rs lib/agents/src/handlers/mod.rs +5 −0 View lib/agents/src/handlers/mod.rs
@@ -0,0 +1,5 @@
1pub mod agents;
2pub mod avatar;
3pub mod builder;
4pub mod chat;
5pub mod collections;
Added lib/agents/src/services/agents.rs lib/agents/src/services/agents.rs +384 −0 View lib/agents/src/services/agents.rs
@@ -0,0 +1,384 @@
1use models::agent::agent_item as agent;
2use models::agent::agents;
3use sandbox_rpc::proto;
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
6 QuerySelect, Set, TransactionTrait,
7};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use utoipa::{IntoParams, ToSchema};
11use uuid::Uuid;
12
13use crate::errors::AgentError;
14use crate::services::service::AgentsService;
15use crate::share::authz::require_collection_manage;
16use crate::share::lookup::{agent_context, collection_context, lookup_collection, space_context};
17
18#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
19pub struct AgentDto {
20 pub id: Uuid,
21 pub collection_id: Uuid,
22 pub number: i32,
23 pub author_id: Uuid,
24 pub author: Option<space::services::spaces::UserDto>,
25 pub name: String,
26 pub description: Option<String>,
27 pub instructions: Option<String>,
28 /// Session-scoped avatar URL when the agent has one uploaded.
29 pub avatar_url: Option<String>,
30 /// Conversation starters shown in the empty chat state (at most three).
31 pub starters: Vec<String>,
32 pub conversation_id: Option<String>,
33 pub last_run_id: Option<String>,
34 pub is_archived: bool,
35 pub created_at: OffsetDateTime,
36 pub updated_at: Option<OffsetDateTime>,
37}
38
39pub type AgentPage = export::pagination::Page<AgentDto>;
40
41#[derive(Debug, Clone, Deserialize, ToSchema)]
42pub struct CreateAgentCmd {
43 pub name: String,
44 #[serde(default)]
45 pub description: Option<String>,
46 #[serde(default)]
47 pub instructions: Option<String>,
48 #[serde(default)]
49 pub starters: Option<Vec<String>>,
50}
51
52#[derive(Debug, Clone, Deserialize, ToSchema)]
53pub struct UpdateAgentCmd {
54 #[serde(default)]
55 pub name: Option<String>,
56 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
57 pub description: Option<Option<String>>,
58 #[serde(default)]
59 pub starters: Option<Vec<String>>,
60}
61
62#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
63pub struct ListAgentQuery {
64 pub q: Option<String>,
65 pub limit: Option<u64>,
66 pub offset: Option<u64>,
67}
68
69#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
70pub struct AgentPath {
71 pub space_name: String,
72 pub collection_id: Uuid,
73 pub agent_id: Uuid,
74}
75
76impl AgentsService {
77 pub(crate) fn agent_dto(
78 row: agent::Model,
79 author: Option<space::services::spaces::UserDto>,
80 space_name: &str,
81 ) -> AgentDto {
82 let avatar_url = row
83 .avatar_url
84 .as_deref()
85 .filter(|url| !url.is_empty())
86 .map(|_| {
87 let version = row.updated_at.unwrap_or(row.created_at).unix_timestamp();
88 format!(
89 "/api/v1/agents/{space_name}/collections/{}/agents/{}/avatar?v={version}",
90 row.collection_id, row.id
91 )
92 });
93 let starters = starters_of(&row);
94 AgentDto {
95 id: row.id,
96 collection_id: row.collection_id,
97 number: row.autoincrement_id,
98 author_id: row.author_id,
99 author,
100 name: row.name,
101 description: row.description,
102 instructions: row.instructions,
103 avatar_url,
104 starters,
105 conversation_id: row.conversation_id,
106 last_run_id: row.last_run_id,
107 is_archived: row.is_archived,
108 created_at: row.created_at,
109 updated_at: row.updated_at,
110 }
111 }
112
113 pub async fn create_agent(
114 &self,
115 caller: Uuid,
116 space_name: &str,
117 collection_id: Uuid,
118 cmd: CreateAgentCmd,
119 ) -> Result<AgentDto, AgentError> {
120 let space = space_context(self, caller, space_name).await?;
121 require_collection_manage(self, &space, caller).await?;
122 let collection = lookup_collection(self, &space, collection_id, caller).await?;
123
124 let name = cmd.name.trim();
125 if name.is_empty() || name.chars().count() > self.config().agents.max_agent_name_len {
126 return Err(AgentError::BadRequest(
127 "agent name is empty or too long".to_string(),
128 ));
129 }
130 let instructions = normalize_optional(cmd.instructions);
131 if let Some(ref text) = instructions
132 && text.chars().count() > self.config().agents.max_instructions_len
133 {
134 return Err(AgentError::BadRequest(
135 "agent instructions are too long".to_string(),
136 ));
137 }
138 let description = normalize_optional(cmd.description);
139 let starters = normalize_starters(cmd.starters)?;
140
141 let rpc = self.rpc()?;
142
143 let now = OffsetDateTime::now_utc();
144 let agent_id = Uuid::now_v7();
145 let inserted = {
146 let txn = self.db().write().begin().await?;
147 let _ = agents::Entity::find_by_id(collection.id)
148 .lock_exclusive()
149 .one(&txn)
150 .await?;
151 let max_row = agent::Entity::find()
152 .filter(agent::Column::CollectionId.eq(collection.id))
153 .order_by_desc(agent::Column::AutoincrementId)
154 .one(&txn)
155 .await?;
156 let next_number = max_row.map(|row| row.autoincrement_id + 1).unwrap_or(1);
157
158 let row = agent::ActiveModel {
159 id: Set(agent_id),
160 collection_id: Set(collection.id),
161 autoincrement_id: Set(next_number),
162 author_id: Set(caller),
163 name: Set(name.to_string()),
164 description: Set(description.clone()),
165 instructions: Set(instructions.clone()),
166 avatar_url: Set(None),
167 starters: Set(Some(serde_json::json!(starters))),
168 conversation_id: Set(None),
169 last_run_id: Set(None),
170 is_archived: Set(false),
171 created_at: Set(now),
172 updated_at: Set(None),
173 };
174 let inserted = row.insert(&txn).await?;
175 txn.commit().await?;
176 inserted
177 };
178
179 let spec = proto::AgentSpecMessage {
180 name: Some(name.to_string()),
181 description,
182 instructions,
183 space: Some(space.id.to_string()),
184 user_id: Some(caller.to_string()),
185 ..Default::default()
186 };
187
188 let session = match rpc
189 .create_session(&space.id.to_string(), &caller.to_string(), name, spec)
190 .await
191 {
192 Ok(session) => session,
193 Err(error) => {
194 if let Err(cleanup) = agent::Entity::delete_by_id(inserted.id)
195 .exec(self.db().write())
196 .await
197 {
198 tracing::warn!(%cleanup, agent_id = %inserted.id, "orphan agent cleanup failed");
199 }
200 return Err(error.into());
201 }
202 };
203
204 let mut am: agent::ActiveModel = inserted.into();
205 am.conversation_id = Set(Some(session.conversation_id));
206 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
207 let updated = am.update(self.db().write()).await?;
208
209 let users = self.load_user_briefs(&[updated.author_id]).await?;
210 let author = users.get(&updated.author_id).cloned();
211 Ok(Self::agent_dto(updated, author, &space.lower_name))
212 }
213
214 pub async fn list_agents(
215 &self,
216 caller: Uuid,
217 space_name: &str,
218 collection_id: Uuid,
219 q: ListAgentQuery,
220 ) -> Result<AgentPage, AgentError> {
221 let (space, collection) = collection_context(self, caller, space_name, collection_id).await?;
222 let (limit, offset) = self.paginate(q.limit, q.offset);
223
224 let mut select =
225 agent::Entity::find().filter(agent::Column::CollectionId.eq(collection.id));
226 if let Some(ref search) = q.q {
227 let kw = search.trim();
228 if !kw.is_empty() {
229 select = select.filter(agent::Column::Name.contains(kw));
230 }
231 }
232 select = select.order_by_asc(agent::Column::AutoincrementId);
233
234 let total = select.clone().count(self.db().read()).await? as i64;
235 let rows = select
236 .limit(limit)
237 .offset(offset)
238 .all(self.db().read())
239 .await?;
240
241 let author_ids: Vec<Uuid> = rows.iter().map(|row| row.author_id).collect();
242 let users = self.load_user_briefs(&author_ids).await?;
243 let items = rows
244 .into_iter()
245 .map(|row| {
246 let author = users.get(&row.author_id).cloned();
247 Self::agent_dto(row, author, &space.lower_name)
248 })
249 .collect();
250
251 Ok(AgentPage {
252 items,
253 total,
254 limit,
255 offset,
256 })
257 }
258
259 pub async fn get_agent(
260 &self,
261 caller: Uuid,
262 space_name: &str,
263 collection_id: Uuid,
264 agent_id: Uuid,
265 ) -> Result<AgentDto, AgentError> {
266 let (space, _, row) = agent_context(self, caller, space_name, collection_id, agent_id).await?;
267 let users = self.load_user_briefs(&[row.author_id]).await?;
268 let author = users.get(&row.author_id).cloned();
269 Ok(Self::agent_dto(row, author, &space.lower_name))
270 }
271
272 pub async fn update_agent(
273 &self,
274 caller: Uuid,
275 space_name: &str,
276 collection_id: Uuid,
277 agent_id: Uuid,
278 cmd: UpdateAgentCmd,
279 ) -> Result<AgentDto, AgentError> {
280 let space = space_context(self, caller, space_name).await?;
281 require_collection_manage(self, &space, caller).await?;
282 let collection = lookup_collection(self, &space, collection_id, caller).await?;
283 let row =
284 crate::share::lookup::lookup_agent(self, &collection, &agent_id.to_string()).await?;
285
286 let mut am: agent::ActiveModel = row.into();
287 if let Some(new_name) = cmd.name {
288 let name = new_name.trim();
289 if name.is_empty() || name.chars().count() > self.config().agents.max_agent_name_len {
290 return Err(AgentError::BadRequest(
291 "agent name is empty or too long".to_string(),
292 ));
293 }
294 am.name = Set(name.to_string());
295 }
296 if let Some(description) = cmd.description {
297 am.description = Set(normalize_optional(description));
298 }
299 if let Some(starters) = cmd.starters {
300 am.starters = Set(Some(serde_json::json!(normalize_starters(Some(starters))?)));
301 }
302 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
303 let updated = am.update(self.db().write()).await?;
304
305 let users = self.load_user_briefs(&[updated.author_id]).await?;
306 let author = users.get(&updated.author_id).cloned();
307 Ok(Self::agent_dto(updated, author, &space.lower_name))
308 }
309
310 pub async fn delete_agent(
311 &self,
312 caller: Uuid,
313 space_name: &str,
314 collection_id: Uuid,
315 agent_id: Uuid,
316 ) -> Result<(), AgentError> {
317 let space = space_context(self, caller, space_name).await?;
318 require_collection_manage(self, &space, caller).await?;
319 let (_, _, row) = agent_context(self, caller, space_name, collection_id, agent_id).await?;
320
321 if let (Some(rpc), Some(conversation_id)) = (self.rpc_opt(), row.conversation_id.as_deref())
322 && let Err(error) = rpc
323 .delete_session(&space.id.to_string(), &caller.to_string(), conversation_id)
324 .await
325 {
326 tracing::warn!(%error, agent_id = %row.id, "sandbox session cleanup failed");
327 }
328
329 agent::Entity::delete_by_id(row.id)
330 .exec(self.db().write())
331 .await?;
332 Ok(())
333 }
334}
335
336/// Conversation starters are a UI affordance: keep at most three short lines.
337const MAX_STARTERS: usize = 3;
338const MAX_STARTER_CHARS: usize = 120;
339
340fn normalize_starters(raw: Option<Vec<String>>) -> Result<Vec<String>, AgentError> {
341 let mut starters = Vec::new();
342 for item in raw.unwrap_or_default() {
343 let text = item.trim();
344 if text.is_empty() {
345 continue;
346 }
347 if text.chars().count() > MAX_STARTER_CHARS {
348 return Err(AgentError::BadRequest(
349 "conversation starter is too long".to_string(),
350 ));
351 }
352 starters.push(text.to_string());
353 if starters.len() > MAX_STARTERS {
354 return Err(AgentError::BadRequest(
355 "at most three conversation starters are supported".to_string(),
356 ));
357 }
358 }
359 Ok(starters)
360}
361
362fn starters_of(row: &agent::Model) -> Vec<String> {
363 row.starters
364 .as_ref()
365 .and_then(|value| value.as_array())
366 .map(|items| {
367 items
368 .iter()
369 .filter_map(|item| item.as_str().map(str::to_string))
370 .collect()
371 })
372 .unwrap_or_default()
373}
374
375fn normalize_optional(value: Option<String>) -> Option<String> {
376 value.and_then(|text| {
377 let trimmed = text.trim();
378 if trimmed.is_empty() {
379 None
380 } else {
381 Some(trimmed.to_string())
382 }
383 })
384}
Added lib/agents/src/services/avatar.rs lib/agents/src/services/avatar.rs +118 −0 View lib/agents/src/services/avatar.rs
@@ -0,0 +1,118 @@
1use sea_orm::{ActiveModelTrait, Set};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5use models::agent::agent_item as agent;
6
7use crate::errors::AgentError;
8use crate::services::agents::AgentDto;
9use crate::services::service::AgentsService;
10use crate::share::authz::require_collection_manage;
11use crate::share::lookup::agent_context;
12
13pub const MAX_AGENT_AVATAR_BYTES: usize = 2 * 1024 * 1024;
14const AGENT_AVATAR_PREFIX: &str = "agent-avatars";
15const AGENT_AVATAR_URL_TTL_SECS: u32 = 6 * 60 * 60;
16
17fn avatar_cache_key(agent_id: Uuid) -> String {
18 format!("agent:avatar:url:{agent_id}")
19}
20
21impl AgentsService {
22 /// Stores the raw avatar bytes in object storage and points the agent row at
23 /// the new object; returns the refreshed agent DTO.
24 pub async fn upload_agent_avatar(
25 &self,
26 caller: Uuid,
27 space_name: &str,
28 collection_id: Uuid,
29 agent_id: Uuid,
30 content_type: &str,
31 bytes: &[u8],
32 ) -> Result<AgentDto, AgentError> {
33 let (space, _, row) = agent_context(self, caller, space_name, collection_id, agent_id).await?;
34 require_collection_manage(self, &space, caller).await?;
35 if bytes.is_empty() {
36 return Err(AgentError::BadRequest("avatar image is empty".to_string()));
37 }
38 if bytes.len() > MAX_AGENT_AVATAR_BYTES {
39 return Err(AgentError::BadRequest(
40 "avatar image is larger than 2 MB".to_string(),
41 ));
42 }
43 let clean = content_type.split(';').next().unwrap_or(content_type).trim().to_lowercase();
44 let extension = match clean.as_str() {
45 "image/jpeg" | "image/jpg" => "jpg",
46 "image/png" => "png",
47 "image/webp" => "webp",
48 "image/gif" => "gif",
49 _ => {
50 return Err(AgentError::BadRequest(
51 "unsupported avatar image type".to_string(),
52 ))
53 }
54 };
55 let key = format!("{AGENT_AVATAR_PREFIX}/{agent_id}.{extension}");
56 self.state
57 .storage
58 .client()
59 .put_with_content_type(&key, bytes, content_type)
60 .await
61 .map_err(|error| AgentError::Internal(error.to_string()))?;
62
63 let mut am: agent::ActiveModel = row.into();
64 am.avatar_url = Set(Some(format!("storage://{key}")));
65 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
66 let updated = am.update(self.db().write()).await?;
67 if let Err(error) = export::redis::delete(&self.state.redis, &avatar_cache_key(agent_id)).await {
68 tracing::warn!(%error, "failed to drop cached agent avatar url");
69 }
70
71 let users = self.load_user_briefs(&[updated.author_id]).await?;
72 let author = users.get(&updated.author_id).cloned();
73 Ok(Self::agent_dto(updated, author, &space.lower_name))
74 }
75
76 /// Presigned URL for the agent avatar; cached so repeated avatar renders do
77 /// not re-sign on every request.
78 pub async fn agent_avatar_url(
79 &self,
80 caller: Uuid,
81 space_name: &str,
82 collection_id: Uuid,
83 agent_id: Uuid,
84 ) -> Result<String, AgentError> {
85 let (_, _, row) = agent_context(self, caller, space_name, collection_id, agent_id).await?;
86 let locator = row
87 .avatar_url
88 .filter(|url| !url.is_empty())
89 .ok_or(AgentError::NotFound)?;
90 let key = locator
91 .strip_prefix("storage://")
92 .ok_or(AgentError::NotFound)?;
93
94 let cache_key = avatar_cache_key(agent_id);
95 match export::redis::get_string(&self.state.redis, &cache_key).await {
96 Ok(Some(url)) => return Ok(url),
97 Ok(None) => {}
98 Err(error) => tracing::warn!(%error, "failed to read agent avatar url cache"),
99 }
100 let url = self
101 .state
102 .storage
103 .client()
104 .presign_get(key, AGENT_AVATAR_URL_TTL_SECS)
105 .map_err(|error| AgentError::Internal(error.to_string()))?;
106 if let Err(error) = export::redis::set_string(
107 &self.state.redis,
108 &cache_key,
109 &url,
110 AGENT_AVATAR_URL_TTL_SECS as u64,
111 )
112 .await
113 {
114 tracing::warn!(%error, "failed to cache agent avatar url");
115 }
116 Ok(url)
117 }
118}
No newline at end of file
Added lib/agents/src/services/builder.rs lib/agents/src/services/builder.rs +181 −0 View lib/agents/src/services/builder.rs
@@ -0,0 +1,181 @@
1use serde::{Deserialize, Serialize};
2use utoipa::ToSchema;
3use uuid::Uuid;
4
5use crate::errors::AgentError;
6use crate::services::service::AgentsService;
7use crate::share::authz::require_collection_manage;
8use crate::share::lookup::{collection_context, space_context};
9
10/// One turn of the builder conversation (client-owned history).
11#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
12pub struct BuilderMessage {
13 pub role: String,
14 pub content: String,
15}
16
17#[derive(Debug, Clone, Deserialize, ToSchema)]
18pub struct BuilderChatCmd {
19 #[serde(default)]
20 pub messages: Vec<BuilderMessage>,
21}
22
23/// Draft the builder proposes; every field is optional and replaces the matching
24/// form field on the client when present.
25#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
26pub struct BuilderDraft {
27 #[serde(default)]
28 pub name: Option<String>,
29 #[serde(default)]
30 pub description: Option<String>,
31 #[serde(default)]
32 pub instructions: Option<String>,
33 #[serde(default)]
34 pub starters: Vec<String>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
38pub struct BuilderChatResult {
39 pub reply: String,
40 pub draft: BuilderDraft,
41}
42
43const MAX_BUILDER_MESSAGES: usize = 60;
44const MAX_BUILDER_CHARS: usize = 24_000;
45
46const BUILDER_SYSTEM_PROMPT: &str = r#"你是 GitData 平台的“智能体构建器”。
47用户会用自然语言描述他想创建的智能体;你的职责是澄清需求,并把它整理成一份可直接使用的智能体配置。
48
49规则:
50- reply:用简洁的中文回复用户(1-3 句)。信息不足时提出一个最关键的澄清问题;信息足够时告知配置已更新。
51- draft:始终输出当前完整的草稿。字段说明:
52 - name:智能体名称,2-20 个字,简洁具体。
53 - description:一句话描述这个智能体做什么(不超过 60 字)。
54 - instructions:写给该智能体的系统提示词,中文,具体、可执行;包含职责、工作方式、关注点与边界(该做什么、避免什么)。
55 - starters:最多 3 条对话开场建议,每条一句话,以提问口吻提出用户可以立刻布置的任务。
56- 用户没有要求修改的字段,保持上一版内容,不要丢失字段。
57- 只输出一个 JSON 对象,不要输出 Markdown 代码块、思考过程或额外解释。
58
59JSON 结构:
60{"reply":"...","draft":{"name":"...","description":"...","instructions":"...","starters":["..."]}}"#;
61
62impl AgentsService {
63 /// One step of the agent builder: runs the platform model over the draft
64 /// conversation and returns the assistant reply plus the updated draft.
65 pub async fn builder_chat(
66 &self,
67 caller: Uuid,
68 space_name: &str,
69 collection_id: Uuid,
70 cmd: BuilderChatCmd,
71 ) -> Result<BuilderChatResult, AgentError> {
72 let space = space_context(self, caller, space_name).await?;
73 require_collection_manage(self, &space, caller).await?;
74 let _ = collection_context(self, caller, space_name, collection_id).await?;
75
76 if cmd.messages.iter().all(|message| message.content.trim().is_empty()) {
77 return Err(AgentError::BadRequest(
78 "builder conversation is empty".to_string(),
79 ));
80 }
81 if cmd.messages.len() > MAX_BUILDER_MESSAGES {
82 return Err(AgentError::BadRequest(
83 "builder conversation is too long".to_string(),
84 ));
85 }
86 let mut transcript = String::new();
87 for message in &cmd.messages {
88 let text = message.content.trim();
89 if text.is_empty() {
90 continue;
91 }
92 if transcript.chars().count() > MAX_BUILDER_CHARS {
93 return Err(AgentError::BadRequest(
94 "builder conversation is too long".to_string(),
95 ));
96 }
97 let who = if message.role == "assistant" {
98 "构建器"
99 } else {
100 "用户"
101 };
102 transcript.push_str(who);
103 transcript.push(':');
104 transcript.push_str(text);
105 transcript.push('\n');
106 }
107
108 if !self.config().ai.enabled {
109 return Err(AgentError::RuntimeUnavailable(
110 "ai is not configured".to_string(),
111 ));
112 }
113 let client = agent::client::AgentClient::new(&self.config().ai)
114 .map_err(|error| AgentError::RuntimeUnavailable(error.to_string()))?;
115 let user_prompt = format!(
116 "构建对话记录:\n{transcript}\n请回复用户(reply),并输出更新后的完整草稿(draft)。"
117 );
118 let completion = client
119 .complete(BUILDER_SYSTEM_PROMPT, &user_prompt, true)
120 .await
121 .map_err(builder_error)?;
122 let value = agent::client::extract_json(&completion.content).map_err(builder_error)?;
123
124 let reply = value
125 .get("reply")
126 .and_then(|item| item.as_str())
127 .map(str::trim)
128 .filter(|text| !text.is_empty())
129 .unwrap_or("已更新右侧配置,可以继续补充需求。")
130 .to_string();
131 Ok(BuilderChatResult {
132 reply,
133 draft: parse_draft(value.get("draft")),
134 })
135 }
136}
137
138fn parse_draft(value: Option<&serde_json::Value>) -> BuilderDraft {
139 let Some(map) = value.and_then(|item| item.as_object()) else {
140 return BuilderDraft::default();
141 };
142 let text = |key: &str| {
143 map.get(key)
144 .and_then(|item| item.as_str())
145 .map(str::trim)
146 .filter(|item| !item.is_empty())
147 .map(str::to_string)
148 };
149 let starters = map
150 .get("starters")
151 .and_then(|item| item.as_array())
152 .map(|items| {
153 items
154 .iter()
155 .filter_map(|item| item.as_str())
156 .map(str::trim)
157 .filter(|item| !item.is_empty())
158 .map(|item| item.chars().take(120).collect::<String>())
159 .take(3)
160 .collect()
161 })
162 .unwrap_or_default();
163 BuilderDraft {
164 name: text("name"),
165 description: text("description"),
166 instructions: text("instructions"),
167 starters,
168 }
169}
170
171fn builder_error(error: agent::client::AgentError) -> AgentError {
172 match error {
173 agent::client::AgentError::NotConfigured(message) => {
174 AgentError::RuntimeUnavailable(message)
175 }
176 agent::client::AgentError::Rejected { .. } => {
177 AgentError::BadRequest(error.to_string())
178 }
179 other => AgentError::Internal(other.to_string()),
180 }
181}
No newline at end of file
Added lib/agents/src/services/chat.rs lib/agents/src/services/chat.rs +684 −0 View lib/agents/src/services/chat.rs
@@ -0,0 +1,684 @@
1use std::collections::HashMap;
2
3use sandbox_rpc::proto;
4use sea_orm::{ActiveModelTrait, EntityTrait, Set};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::{IntoParams, ToSchema};
8use uuid::Uuid;
9
10use crate::errors::AgentError;
11use crate::services::service::AgentsService;
12use crate::share::lookup::agent_context;
13use models::agent::agent_item as agent;
14use models::space::spaces;
15
16#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
17pub struct AgentRunDto {
18 pub run_id: String,
19 pub conversation_id: String,
20 pub status: String,
21 pub output: Option<String>,
22 pub error: Option<String>,
23 pub input_tokens: i64,
24 pub output_tokens: i64,
25 pub total_tokens: i64,
26 pub created_at: i64,
27 pub finished_at: Option<i64>,
28}
29
30impl From<proto::Run> for AgentRunDto {
31 fn from(run: proto::Run) -> Self {
32 Self {
33 run_id: run.run_id,
34 conversation_id: run.conversation_id,
35 status: run.status,
36 output: run.output,
37 error: run.error,
38 input_tokens: run.input_tokens,
39 output_tokens: run.output_tokens,
40 total_tokens: run.total_tokens,
41 created_at: run.created_at,
42 finished_at: run.finished_at,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
48pub struct AgentMessageDto {
49 pub message_id: String,
50 pub conversation_id: String,
51 pub run_id: Option<String>,
52 pub role: String,
53 pub kind: String,
54 pub status: String,
55 pub content: String,
56 pub sequence: i64,
57 pub created_at: i64,
58 /// Present on tool messages: links the rendered result back to the tool
59 /// call (command, duration, status) recorded by the runtime.
60 pub external_call_id: Option<String>,
61}
62
63impl From<proto::Message> for AgentMessageDto {
64 fn from(message: proto::Message) -> Self {
65 Self {
66 message_id: message.message_id,
67 conversation_id: message.conversation_id,
68 run_id: message.run_id,
69 role: message.role,
70 kind: message.kind,
71 status: message.status,
72 content: message.content,
73 sequence: message.sequence,
74 created_at: message.created_at,
75 external_call_id: message.external_call_id,
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
81pub struct AgentToolCallDto {
82 pub tool_call_id: String,
83 pub conversation_id: String,
84 pub run_id: String,
85 pub call_id: String,
86 pub name: String,
87 pub kind: String,
88 pub status: String,
89 pub input: Option<String>,
90 pub duration_ms: Option<i64>,
91 pub created_at: i64,
92 pub finished_at: Option<i64>,
93}
94
95impl From<proto::ToolCall> for AgentToolCallDto {
96 fn from(call: proto::ToolCall) -> Self {
97 Self {
98 tool_call_id: call.tool_call_id,
99 conversation_id: call.conversation_id,
100 run_id: call.run_id,
101 call_id: call.call_id,
102 name: call.name,
103 kind: call.kind,
104 status: call.status,
105 input: call.input,
106 duration_ms: call.duration_ms,
107 created_at: call.created_at,
108 finished_at: call.finished_at,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
114pub struct AgentApprovalDto {
115 pub approval_id: String,
116 pub run_id: String,
117 pub conversation_id: String,
118 pub action: String,
119 pub tool_name: Option<String>,
120 pub status: String,
121 pub scope: String,
122 pub reason: Option<String>,
123 pub decision_reason: Option<String>,
124 pub requested_at: i64,
125 pub decided_at: Option<i64>,
126}
127
128impl From<proto::Approval> for AgentApprovalDto {
129 fn from(approval: proto::Approval) -> Self {
130 Self {
131 approval_id: approval.approval_id,
132 run_id: approval.run_id,
133 conversation_id: approval.conversation_id,
134 action: approval.action,
135 tool_name: approval.tool_name,
136 status: approval.status,
137 scope: approval.scope,
138 reason: approval.reason,
139 decision_reason: approval.decision_reason,
140 requested_at: approval.requested_at,
141 decided_at: approval.decided_at,
142 }
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
147pub struct AgentQuestionOptionDto {
148 pub label: String,
149 pub description: Option<String>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
153pub struct AgentQuestionItemDto {
154 pub item_id: String,
155 pub question: String,
156 pub header: Option<String>,
157 pub options: Vec<AgentQuestionOptionDto>,
158 pub multiple: bool,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
162pub struct AgentQuestionAnswerDto {
163 pub item_id: String,
164 pub selected: Vec<String>,
165 pub text: Option<String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
169pub struct AgentQuestionDto {
170 pub question_id: String,
171 pub run_id: String,
172 pub conversation_id: String,
173 pub status: String,
174 pub questions: Vec<AgentQuestionItemDto>,
175 pub answers: Vec<AgentQuestionAnswerDto>,
176 pub answered_by: Option<String>,
177 pub requested_at: i64,
178 pub answered_at: Option<i64>,
179}
180
181impl From<proto::Question> for AgentQuestionDto {
182 fn from(question: proto::Question) -> Self {
183 Self {
184 question_id: question.question_id,
185 run_id: question.run_id,
186 conversation_id: question.conversation_id,
187 status: question.status,
188 questions: question
189 .questions
190 .into_iter()
191 .map(|item| AgentQuestionItemDto {
192 item_id: item.item_id,
193 question: item.question,
194 header: item.header,
195 options: item
196 .options
197 .into_iter()
198 .map(|option| AgentQuestionOptionDto {
199 label: option.label,
200 description: option.description,
201 })
202 .collect(),
203 multiple: item.multiple,
204 })
205 .collect(),
206 answers: question
207 .answers
208 .into_iter()
209 .map(|answer| AgentQuestionAnswerDto {
210 item_id: answer.item_id,
211 selected: answer.selected,
212 text: answer.text,
213 })
214 .collect(),
215 answered_by: question.answered_by,
216 requested_at: question.requested_at,
217 answered_at: question.answered_at,
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
223pub struct AgentStreamTokenDto {
224 /// Short-lived JWT for the runtime's fanout SSE (`?token=`).
225 pub token: String,
226 pub expires_at: i64,
227 /// Browser-facing base of the fanout service, e.g. `/sandbox`.
228 pub fanout_endpoint: String,
229 pub conversation_id: String,
230}
231
232#[derive(Debug, Clone, Deserialize, ToSchema)]
233pub struct SendAgentMessageCmd {
234 pub text: String,
235 /// Attach the platform's own MCP endpoint (GitData tools) to this run.
236 #[serde(default)]
237 pub mcp: bool,
238 /// Bind a repository (name inside the space) to the run's context.
239 #[serde(default)]
240 pub repo: Option<String>,
241}
242
243#[derive(Debug, Clone, Deserialize, ToSchema)]
244pub struct DecideAgentApprovalCmd {
245 pub decision: String,
246 #[serde(default)]
247 pub reason: Option<String>,
248 #[serde(default)]
249 pub scope: Option<String>,
250}
251
252#[derive(Debug, Clone, Deserialize, ToSchema)]
253pub struct AgentQuestionAnswerCmd {
254 pub item_id: String,
255 #[serde(default)]
256 pub selected: Vec<String>,
257 #[serde(default)]
258 pub text: Option<String>,
259}
260
261#[derive(Debug, Clone, Deserialize, ToSchema)]
262pub struct AnswerAgentQuestionCmd {
263 pub answers: Vec<AgentQuestionAnswerCmd>,
264}
265
266#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
267pub struct ListAgentMessageQuery {
268 pub after_sequence: Option<i64>,
269 pub limit: Option<u64>,
270 pub run_id: Option<String>,
271}
272
273#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
274pub struct ListAgentToolCallQuery {
275 pub limit: Option<u64>,
276}
277
278#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
279pub struct AgentRunPath {
280 pub space_name: String,
281 pub collection_id: Uuid,
282 pub agent_id: Uuid,
283 pub run_id: String,
284}
285
286#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
287pub struct AgentApprovalPath {
288 pub space_name: String,
289 pub collection_id: Uuid,
290 pub agent_id: Uuid,
291 pub approval_id: String,
292}
293
294#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
295pub struct AgentQuestionPath {
296 pub space_name: String,
297 pub collection_id: Uuid,
298 pub agent_id: Uuid,
299 pub question_id: String,
300}
301
302impl AgentsService {
303 /// Mint the browser's fanout stream grant. The web client then connects to
304 /// the runtime's SSE endpoints directly (`/events`, `/tokens`) with `?token=`;
305 /// the main API is not in the data path.
306 pub async fn create_agent_stream_token(
307 &self,
308 caller: Uuid,
309 space_name: &str,
310 collection_id: Uuid,
311 agent_id: Uuid,
312 ) -> Result<AgentStreamTokenDto, AgentError> {
313 let (space, _, row) =
314 agent_context(self, caller, space_name, collection_id, agent_id).await?;
315 let conversation_id = row
316 .conversation_id
317 .ok_or_else(|| AgentError::BadRequest("agent has no runtime session".to_string()))?;
318
319 let rpc = self.rpc()?;
320 let fanout_endpoint = rpc.fanout_endpoint().trim();
321 if fanout_endpoint.is_empty() {
322 return Err(AgentError::RuntimeUnavailable(
323 "sandbox event stream is not configured".to_string(),
324 ));
325 }
326
327 let issued = rpc
328 .issue_token(
329 &space.id.to_string(),
330 &caller.to_string(),
331 vec![sandbox_rpc::SCOPE_STREAM.to_string()],
332 Some(rpc.stream_token_ttl_secs()),
333 )
334 .await?;
335
336 Ok(AgentStreamTokenDto {
337 token: issued.token,
338 expires_at: issued.expires_at,
339 fanout_endpoint: fanout_endpoint.to_string(),
340 conversation_id,
341 })
342 }
343
344 async fn conversation(
345 &self,
346 caller: Uuid,
347 space_name: &str,
348 collection_id: Uuid,
349 agent_id: Uuid,
350 ) -> Result<(String, String, String), AgentError> {
351 let (space, _, row) =
352 agent_context(self, caller, space_name, collection_id, agent_id).await?;
353 let conversation_id = row
354 .conversation_id
355 .ok_or_else(|| AgentError::BadRequest("agent has no runtime session".to_string()))?;
356 Ok((space.id.to_string(), caller.to_string(), conversation_id))
357 }
358
359 pub async fn send_agent_message(
360 &self,
361 caller: Uuid,
362 space_name: &str,
363 collection_id: Uuid,
364 agent_id: Uuid,
365 cmd: SendAgentMessageCmd,
366 ) -> Result<AgentRunDto, AgentError> {
367 let text = cmd.text.trim();
368 if text.is_empty() {
369 return Err(AgentError::BadRequest("message text is empty".to_string()));
370 }
371 let (space, _, row) =
372 agent_context(self, caller, space_name, collection_id, agent_id).await?;
373 let conversation_id = row
374 .conversation_id
375 .ok_or_else(|| AgentError::BadRequest("agent has no runtime session".to_string()))?;
376
377 let mcp_servers = if cmd.mcp {
378 self.run_mcp_servers(&space, caller).await?
379 } else {
380 Vec::new()
381 };
382 let repo = match cmd.repo.as_deref().map(str::trim).filter(|repo| !repo.is_empty()) {
383 Some(repo) => Some(self.authorize_repo(&space, caller, repo).await?),
384 None => None,
385 };
386 let context = run_context(&space, repo.as_deref(), !mcp_servers.is_empty());
387
388 let rpc = self.rpc()?;
389 let run = rpc
390 .send_message(
391 &space.id.to_string(),
392 &caller.to_string(),
393 &conversation_id,
394 text,
395 mcp_servers,
396 Some(context),
397 )
398 .await?;
399
400 let mut am: agent::ActiveModel = agent::Entity::find_by_id(agent_id)
401 .one(self.db().read())
402 .await?
403 .ok_or(AgentError::NotFound)?
404 .into();
405 am.last_run_id = Set(Some(run.run_id.clone()));
406 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
407 am.update(self.db().write()).await?;
408
409 Ok(run.into())
410 }
411
412 /// Inline MCP server attached to a single run: the platform's own MCP
413 /// endpoint, authenticated with a temporary token confined to this space.
414 async fn run_mcp_servers(
415 &self,
416 space: &spaces::Model,
417 caller: Uuid,
418 ) -> Result<Vec<proto::McpServerSpecMessage>, AgentError> {
419 if !crate::share::authz::can_manage_mcp(self, space, caller).await {
420 return Err(AgentError::Forbidden);
421 }
422 let url = self.config().mcp.runtime_url();
423 if !self.config().mcp.enabled() || url.is_empty() {
424 return Err(AgentError::BadRequest(
425 "mcp endpoint is not configured".to_string(),
426 ));
427 }
428
429 let issued = mcp_api::space_token::issue(
430 &self.state,
431 mcp_api::space_token::IssueSpaceTokenCmd {
432 space: space.lower_name.clone(),
433 ttl_secs: Some(self.config().agents.mcp_token_ttl_secs),
434 issued_by: Some(caller.to_string()),
435 },
436 )
437 .await
438 .map_err(space_token_error)?;
439
440 let headers = HashMap::from([(
441 "Authorization".to_string(),
442 format!("Bearer {}", issued.token),
443 )]);
444 Ok(vec![proto::McpServerSpecMessage {
445 name: "gitdata".to_string(),
446 url: url.to_string(),
447 description: Some(
448 "GitData 平台工具:仓库、工单、合并请求、空间与用户信息".to_string(),
449 ),
450 headers,
451 tool_prefix: None,
452 }])
453 }
454
455 /// Validates a bound repository: it must exist inside the space and the
456 /// caller must be able to read it; returns the canonical repo name.
457 async fn authorize_repo(
458 &self,
459 space: &spaces::Model,
460 caller: Uuid,
461 repo: &str,
462 ) -> Result<String, AgentError> {
463 let git = git::service::GitService::new(self.state.clone());
464 let ctx = git
465 .context(space.lower_name.clone(), repo.to_string())
466 .await
467 .map_err(|_| AgentError::NotFound)?;
468 git::share::access::authorize_repo_access(&git, caller, &ctx, false, false)
469 .await
470 .map_err(|_| AgentError::Forbidden)?;
471 Ok(ctx.model.name.clone())
472 }
473
474 pub async fn list_agent_messages(
475 &self,
476 caller: Uuid,
477 space_name: &str,
478 collection_id: Uuid,
479 agent_id: Uuid,
480 q: ListAgentMessageQuery,
481 ) -> Result<Vec<AgentMessageDto>, AgentError> {
482 let (space_ref, user_ref, conversation_id) = self
483 .conversation(caller, space_name, collection_id, agent_id)
484 .await?;
485 let limit = q.limit.unwrap_or(200).clamp(1, 1000);
486 let rpc = self.rpc()?;
487 let messages = rpc
488 .list_messages(
489 &space_ref,
490 &user_ref,
491 &conversation_id,
492 q.after_sequence,
493 Some(limit),
494 q.run_id,
495 )
496 .await?;
497 Ok(messages.into_iter().map(AgentMessageDto::from).collect())
498 }
499
500 pub async fn list_agent_tool_calls(
501 &self,
502 caller: Uuid,
503 space_name: &str,
504 collection_id: Uuid,
505 agent_id: Uuid,
506 q: ListAgentToolCallQuery,
507 ) -> Result<Vec<AgentToolCallDto>, AgentError> {
508 let (space_ref, user_ref, conversation_id) = self
509 .conversation(caller, space_name, collection_id, agent_id)
510 .await?;
511 let limit = q.limit.unwrap_or(500).clamp(1, 2000);
512 let rpc = self.rpc()?;
513 let calls = rpc
514 .list_tool_calls(&space_ref, &user_ref, &conversation_id, Some(limit))
515 .await?;
516 Ok(calls.into_iter().map(AgentToolCallDto::from).collect())
517 }
518
519 pub async fn list_agent_runs(
520 &self,
521 caller: Uuid,
522 space_name: &str,
523 collection_id: Uuid,
524 agent_id: Uuid,
525 ) -> Result<Vec<AgentRunDto>, AgentError> {
526 let (space_ref, user_ref, conversation_id) = self
527 .conversation(caller, space_name, collection_id, agent_id)
528 .await?;
529 let rpc = self.rpc()?;
530 let runs = rpc
531 .list_runs(&space_ref, &user_ref, &conversation_id)
532 .await?;
533 Ok(runs.into_iter().map(AgentRunDto::from).collect())
534 }
535
536 pub async fn cancel_agent_run(
537 &self,
538 caller: Uuid,
539 space_name: &str,
540 collection_id: Uuid,
541 agent_id: Uuid,
542 run_id: &str,
543 ) -> Result<AgentRunDto, AgentError> {
544 let (space_ref, user_ref, _) = self
545 .conversation(caller, space_name, collection_id, agent_id)
546 .await?;
547 let rpc = self.rpc()?;
548 let run = rpc.cancel_run(&space_ref, &user_ref, run_id).await?;
549 Ok(run.into())
550 }
551
552 pub async fn list_agent_approvals(
553 &self,
554 caller: Uuid,
555 space_name: &str,
556 collection_id: Uuid,
557 agent_id: Uuid,
558 ) -> Result<Vec<AgentApprovalDto>, AgentError> {
559 let (space_ref, user_ref, conversation_id) = self
560 .conversation(caller, space_name, collection_id, agent_id)
561 .await?;
562 let rpc = self.rpc()?;
563 let approvals = rpc
564 .list_approvals(&space_ref, &user_ref, &conversation_id)
565 .await?;
566 Ok(approvals.into_iter().map(AgentApprovalDto::from).collect())
567 }
568
569 pub async fn decide_agent_approval(
570 &self,
571 caller: Uuid,
572 space_name: &str,
573 collection_id: Uuid,
574 agent_id: Uuid,
575 approval_id: &str,
576 cmd: DecideAgentApprovalCmd,
577 ) -> Result<AgentApprovalDto, AgentError> {
578 let decision = cmd.decision.trim();
579 if decision.is_empty() {
580 return Err(AgentError::BadRequest("decision is empty".to_string()));
581 }
582 let (space_ref, user_ref, _) = self
583 .conversation(caller, space_name, collection_id, agent_id)
584 .await?;
585 let rpc = self.rpc()?;
586 let approval = rpc
587 .decide_approval(
588 &space_ref,
589 &user_ref,
590 approval_id,
591 decision,
592 cmd.reason,
593 cmd.scope,
594 )
595 .await?;
596 Ok(approval.into())
597 }
598
599 pub async fn list_agent_questions(
600 &self,
601 caller: Uuid,
602 space_name: &str,
603 collection_id: Uuid,
604 agent_id: Uuid,
605 ) -> Result<Vec<AgentQuestionDto>, AgentError> {
606 let (space_ref, user_ref, conversation_id) = self
607 .conversation(caller, space_name, collection_id, agent_id)
608 .await?;
609 let rpc = self.rpc()?;
610 let questions = rpc
611 .list_questions(&space_ref, &user_ref, &conversation_id)
612 .await?;
613 Ok(questions.into_iter().map(AgentQuestionDto::from).collect())
614 }
615
616 pub async fn answer_agent_questions(
617 &self,
618 caller: Uuid,
619 space_name: &str,
620 collection_id: Uuid,
621 agent_id: Uuid,
622 question_id: &str,
623 cmd: AnswerAgentQuestionCmd,
624 ) -> Result<AgentQuestionDto, AgentError> {
625 if cmd.answers.is_empty() {
626 return Err(AgentError::BadRequest("answers are empty".to_string()));
627 }
628 let (space_ref, user_ref, _) = self
629 .conversation(caller, space_name, collection_id, agent_id)
630 .await?;
631 let answers = cmd
632 .answers
633 .into_iter()
634 .map(|answer| proto::QuestionAnswer {
635 item_id: answer.item_id,
636 selected: answer.selected,
637 text: answer.text,
638 })
639 .collect();
640 let rpc = self.rpc()?;
641 let question = rpc
642 .answer_questions(&space_ref, &user_ref, question_id, answers)
643 .await?;
644 Ok(question.into())
645 }
646}
647
648/// System context injected by the control plane on every run: the agent knows
649/// which space it works in, the bound repository (if any), and how to address
650/// them from the platform MCP tools.
651fn run_context(space: &spaces::Model, repo: Option<&str>, mcp_enabled: bool) -> String {
652 let mut lines = vec![
653 "运行上下文(由 GitData 平台自动注入):".to_string(),
654 format!(
655 "- 当前空间:{}(space_id: {})。你只能访问该空间内的数据。",
656 space.lower_name, space.id
657 ),
658 ];
659 if let Some(repo) = repo {
660 lines.push(format!(
661 "- 当前绑定仓库:{}/{},涉及代码或仓库的操作默认针对该仓库。",
662 space.lower_name, repo
663 ));
664 }
665 if mcp_enabled {
666 let repo_hint = repo
667 .map(|repo| format!(",涉及仓库时 repo 参数使用「{repo}」"))
668 .unwrap_or_default();
669 lines.push(format!(
670 "- 平台 MCP 工具(gitdata)作用于当前空间,调用时 space 参数使用「{}」{repo_hint}。",
671 space.lower_name
672 ));
673 }
674 lines.join("\n")
675}
676
677fn space_token_error(error: mcp_api::space_token::SpaceTokenError) -> AgentError {
678 use mcp_api::space_token::SpaceTokenError;
679 match error {
680 SpaceTokenError::SpaceNotFound => AgentError::NotFound,
681 SpaceTokenError::BotNameConflict(message) => AgentError::Conflict(message),
682 other => AgentError::Internal(other.to_string()),
683 }
684}
No newline at end of file
Added lib/agents/src/services/collections.rs lib/agents/src/services/collections.rs +406 −0 View lib/agents/src/services/collections.rs
@@ -0,0 +1,406 @@
1use std::collections::HashMap;
2
3use models::agent::agent_item as agent;
4use models::agent::agents;
5use models::dir::dirs::{self, DirKind};
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
8 QuerySelect, Set, TransactionTrait, Unchanged,
9};
10use serde::{Deserialize, Serialize};
11use time::OffsetDateTime;
12use utoipa::{IntoParams, ToSchema};
13use uuid::Uuid;
14
15use crate::errors::AgentError;
16use crate::services::service::AgentsService;
17use crate::share::authz::require_collection_manage;
18use crate::share::lookup::{collection_context, lookup_collection, space_context};
19
20#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
21pub struct AgentCollectionDto {
22 pub id: Uuid,
23 pub dir_id: Uuid,
24 pub name: String,
25 pub icon: Option<String>,
26 pub description: Option<String>,
27 pub agent_count: i64,
28 /// Caller may attach the platform MCP to this set's agent runs (space
29 /// management permission plus a runtime-reachable MCP endpoint).
30 pub can_enable_mcp: bool,
31 /// The sandbox runtime bridge is configured for this deployment.
32 pub runtime_online: bool,
33 pub created_at: OffsetDateTime,
34 pub updated_at: Option<OffsetDateTime>,
35}
36
37pub type AgentCollectionPage = export::pagination::Page<AgentCollectionDto>;
38
39#[derive(Debug, Clone, Deserialize, ToSchema)]
40pub struct CreateAgentCollectionCmd {
41 pub name: String,
42 #[serde(default, alias = "dir_id")]
43 pub parent_dir_id: Option<Uuid>,
44 #[serde(default)]
45 pub icon: Option<String>,
46 #[serde(default)]
47 pub description: Option<String>,
48}
49
50#[derive(Debug, Clone, Deserialize, ToSchema)]
51pub struct UpdateAgentCollectionCmd {
52 #[serde(default)]
53 pub name: Option<String>,
54 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
55 pub icon: Option<Option<String>>,
56 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
57 pub description: Option<Option<String>>,
58}
59
60#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
61pub struct ListAgentCollectionQuery {
62 pub dir_id: Option<Uuid>,
63 pub q: Option<String>,
64 pub limit: Option<u64>,
65 pub offset: Option<u64>,
66}
67
68#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
69pub struct AgentCollectionPath {
70 pub space_name: String,
71 pub collection_id: Uuid,
72}
73
74impl AgentsService {
75 fn collection_dto(
76 collection: agents::Model,
77 agent_count: i64,
78 can_enable_mcp: bool,
79 runtime_online: bool,
80 ) -> AgentCollectionDto {
81 AgentCollectionDto {
82 id: collection.id,
83 dir_id: collection.dir_id,
84 name: collection.name,
85 icon: collection.icon,
86 description: collection.description,
87 agent_count,
88 can_enable_mcp,
89 runtime_online,
90 created_at: collection.created_at,
91 updated_at: collection.updated_at,
92 }
93 }
94
95 async fn agent_counts(
96 &self,
97 collection_ids: &[Uuid],
98 ) -> Result<HashMap<Uuid, i64>, AgentError> {
99 if collection_ids.is_empty() {
100 return Ok(HashMap::new());
101 }
102 let rows: Vec<(Uuid, i64)> = agent::Entity::find()
103 .select_only()
104 .column(agent::Column::CollectionId)
105 .column_as(agent::Column::CollectionId.count(), "cnt")
106 .filter(agent::Column::CollectionId.is_in(collection_ids.to_vec()))
107 .group_by(agent::Column::CollectionId)
108 .into_tuple()
109 .all(self.db().read())
110 .await?;
111 Ok(rows.into_iter().collect())
112 }
113
114 pub async fn create_agent_collection(
115 &self,
116 caller: Uuid,
117 space_name: &str,
118 cmd: CreateAgentCollectionCmd,
119 ) -> Result<AgentCollectionDto, AgentError> {
120 let space = space_context(self, caller, space_name).await?;
121 require_collection_manage(self, &space, caller).await?;
122
123 let name = cmd.name.trim();
124 if name.is_empty() || name.chars().count() > self.config().agents.max_collection_name_len {
125 return Err(AgentError::BadRequest(
126 "collection name is empty or too long".to_string(),
127 ));
128 }
129
130 if let Some(p) = cmd.parent_dir_id {
131 let parent = dirs::Entity::find_by_id(p)
132 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
133 .one(self.db().read())
134 .await?
135 .ok_or(AgentError::NotFound)?;
136 if parent.kind != DirKind::Dir {
137 return Err(AgentError::BadRequest(
138 "parent node must be a dir".to_string(),
139 ));
140 }
141 }
142
143 let (dir, collection) = {
144 let txn = self.db().write().begin().await?;
145 let position = {
146 let max_node = dirs::Entity::find()
147 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
148 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
149 .order_by_desc(dirs::Column::Position)
150 .lock(sea_orm::sea_query::LockType::Update)
151 .one(&txn)
152 .await?;
153 max_node.map(|n| n.position + 1).unwrap_or(0)
154 };
155 let now = OffsetDateTime::now_utc();
156 let dir_row = dirs::ActiveModel {
157 id: Set(Uuid::now_v7()),
158 space_id: Set(Some(space.id)),
159 name: Set(name.to_string()),
160 kind: Set(DirKind::Object),
161 parent_dir_id: Set(cmd.parent_dir_id),
162 color: Set(None),
163 position: Set(position),
164 icon: Set(cmd.icon.clone()),
165 topic: Set(None),
166 purpose: Set(cmd.description.clone()),
167 is_archived: Set(false),
168 archived_at: Set(None),
169 archived_by: Set(None),
170 created_at: Set(now),
171 updated_at: Set(now),
172 created_by_uid: Set(caller),
173 };
174 let dir = dir_row.insert(&txn).await?;
175
176 let collection_row = agents::ActiveModel {
177 id: Set(Uuid::now_v7()),
178 dir_id: Set(dir.id),
179 name: Set(name.to_string()),
180 icon: Set(cmd.icon),
181 description: Set(cmd.description),
182 created_at: Set(now),
183 updated_at: Set(None),
184 };
185 let collection = collection_row.insert(&txn).await?;
186 txn.commit().await?;
187 (dir, collection)
188 };
189 let _ = dir;
190
191 let can_enable_mcp = crate::share::authz::can_enable_mcp(self, &space, caller).await;
192 Ok(Self::collection_dto(
193 collection,
194 0,
195 can_enable_mcp,
196 self.rpc_opt().is_some(),
197 ))
198 }
199
200 pub async fn list_agent_collections(
201 &self,
202 caller: Uuid,
203 space_name: &str,
204 q: ListAgentCollectionQuery,
205 ) -> Result<AgentCollectionPage, AgentError> {
206 let space = space_context(self, caller, space_name).await?;
207 let (limit, offset) = self.paginate(q.limit, q.offset);
208
209 let dirs_in_space = dirs::Entity::find()
210 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
211 .filter(dirs::Column::Kind.eq(DirKind::Object))
212 .all(self.db().read())
213 .await?;
214
215 let dir_ids: Vec<Uuid> = if let Some(target_dir) = q.dir_id {
216 dirs_in_space
217 .into_iter()
218 .filter(|d| d.parent_dir_id == Some(target_dir) || d.id == target_dir)
219 .map(|d| d.id)
220 .collect()
221 } else {
222 dirs_in_space.into_iter().map(|d| d.id).collect()
223 };
224
225 if dir_ids.is_empty() {
226 return Ok(AgentCollectionPage {
227 items: Vec::new(),
228 total: 0,
229 limit,
230 offset,
231 });
232 }
233
234 let mut select = agents::Entity::find().filter(agents::Column::DirId.is_in(dir_ids));
235 if let Some(ref search) = q.q {
236 let kw = search.trim();
237 if !kw.is_empty() {
238 select = select.filter(agents::Column::Name.contains(kw));
239 }
240 }
241 select = select.order_by_desc(agents::Column::CreatedAt);
242
243 let total = select.clone().count(self.db().read()).await? as i64;
244 let rows = select
245 .limit(limit)
246 .offset(offset)
247 .all(self.db().read())
248 .await?;
249
250 let can_enable_mcp = crate::share::authz::can_enable_mcp(self, &space, caller).await;
251 let runtime_online = self.rpc_opt().is_some();
252 let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
253 let counts = self.agent_counts(&ids).await?;
254 let items = rows
255 .into_iter()
256 .map(|row| {
257 let count = counts.get(&row.id).copied().unwrap_or(0);
258 Self::collection_dto(row, count, can_enable_mcp, runtime_online)
259 })
260 .collect();
261
262 Ok(AgentCollectionPage {
263 items,
264 total,
265 limit,
266 offset,
267 })
268 }
269
270 pub async fn get_agent_collection(
271 &self,
272 caller: Uuid,
273 space_name: &str,
274 collection_id: Uuid,
275 ) -> Result<AgentCollectionDto, AgentError> {
276 let (space, collection) =
277 collection_context(self, caller, space_name, collection_id).await?;
278 let counts = self.agent_counts(&[collection.id]).await?;
279 let count = counts.get(&collection.id).copied().unwrap_or(0);
280 let can_enable_mcp = crate::share::authz::can_enable_mcp(self, &space, caller).await;
281 Ok(Self::collection_dto(
282 collection,
283 count,
284 can_enable_mcp,
285 self.rpc_opt().is_some(),
286 ))
287 }
288
289 pub async fn update_agent_collection(
290 &self,
291 caller: Uuid,
292 space_name: &str,
293 collection_id: Uuid,
294 cmd: UpdateAgentCollectionCmd,
295 ) -> Result<AgentCollectionDto, AgentError> {
296 let space = space_context(self, caller, space_name).await?;
297 require_collection_manage(self, &space, caller).await?;
298 let collection = lookup_collection(self, &space, collection_id, caller).await?;
299
300 let now = OffsetDateTime::now_utc();
301 let mut am: agents::ActiveModel = collection.clone().into();
302 let mut dir_am = dirs::ActiveModel {
303 id: Unchanged(collection.dir_id),
304 ..Default::default()
305 };
306 let mut dir_changed = false;
307
308 if let Some(new_name) = cmd.name {
309 let name = new_name.trim();
310 if name.is_empty()
311 || name.chars().count() > self.config().agents.max_collection_name_len
312 {
313 return Err(AgentError::BadRequest(
314 "collection name is empty or too long".to_string(),
315 ));
316 }
317 am.name = Set(name.to_string());
318 dir_am.name = Set(name.to_string());
319 dir_changed = true;
320 }
321 if let Some(icon) = cmd.icon {
322 am.icon = Set(icon.clone());
323 dir_am.icon = Set(icon);
324 dir_changed = true;
325 }
326 if let Some(description) = cmd.description {
327 am.description = Set(description.clone());
328 dir_am.purpose = Set(description);
329 dir_changed = true;
330 }
331
332 am.updated_at = Set(Some(now));
333
334 let updated = {
335 let txn = self.db().write().begin().await?;
336 if dir_changed {
337 dir_am.updated_at = Set(now);
338 dir_am.update(&txn).await?;
339 }
340 let updated = am.update(&txn).await?;
341 txn.commit().await?;
342 updated
343 };
344
345 let counts = self.agent_counts(&[updated.id]).await?;
346 let count = counts.get(&updated.id).copied().unwrap_or(0);
347 let can_enable_mcp = crate::share::authz::can_enable_mcp(self, &space, caller).await;
348 Ok(Self::collection_dto(
349 updated,
350 count,
351 can_enable_mcp,
352 self.rpc_opt().is_some(),
353 ))
354 }
355
356 pub async fn delete_agent_collection(
357 &self,
358 caller: Uuid,
359 space_name: &str,
360 collection_id: Uuid,
361 ) -> Result<(), AgentError> {
362 let space = space_context(self, caller, space_name).await?;
363 require_collection_manage(self, &space, caller).await?;
364 let collection = lookup_collection(self, &space, collection_id, caller).await?;
365
366 let rows = agent::Entity::find()
367 .filter(agent::Column::CollectionId.eq(collection.id))
368 .all(self.db().read())
369 .await?;
370
371 if let Some(rpc) = self.rpc_opt() {
372 let space_ref = space.id.to_string();
373 let user_ref = caller.to_string();
374 for row in &rows {
375 if let Some(conversation_id) = row.conversation_id.as_deref()
376 && let Err(error) = rpc
377 .delete_session(&space_ref, &user_ref, conversation_id)
378 .await
379 {
380 tracing::warn!(%error, agent_id = %row.id, "sandbox session cleanup failed");
381 }
382 }
383 }
384
385 self.db()
386 .primary()
387 .transaction::<_, (), AgentError>(|txn| {
388 Box::pin(async move {
389 agent::Entity::delete_many()
390 .filter(agent::Column::CollectionId.eq(collection.id))
391 .exec(txn)
392 .await?;
393 agents::Entity::delete_by_id(collection.id)
394 .exec(txn)
395 .await?;
396 dirs::Entity::delete_by_id(collection.dir_id)
397 .exec(txn)
398 .await?;
399 Ok(())
400 })
401 })
402 .await?;
403
404 Ok(())
405 }
406}
Added lib/agents/src/services/mod.rs lib/agents/src/services/mod.rs +6 −0 View lib/agents/src/services/mod.rs
@@ -0,0 +1,6 @@
1pub mod agents;
2pub mod avatar;
3pub mod builder;
4pub mod chat;
5pub mod collections;
6pub mod service;
Added lib/agents/src/services/service.rs lib/agents/src/services/service.rs +92 −0 View lib/agents/src/services/service.rs
@@ -0,0 +1,92 @@
1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use config::AppConfig;
6use export::AppState;
7use export::database::AppDatabase;
8use models::users::user;
9use sandbox_rpc::SandboxRpcClient;
10use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
11use session_layer::SessionError;
12use session_layer::share::guard::{SessionGuard, SessionWithUser};
13use space::services::spaces::UserDto;
14use uuid::Uuid;
15
16use crate::errors::AgentError;
17
18#[derive(Clone)]
19pub struct AgentsService {
20 pub state: AppState,
21 rpc: Option<SandboxRpcClient>,
22}
23
24impl AgentsService {
25 pub fn new(state: AppState) -> Self {
26 let rpc = match SandboxRpcClient::from_config(&state.config.sandbox_rpc) {
27 Ok(client) => client,
28 Err(error) => {
29 tracing::warn!(%error, "sandbox rpc disabled: invalid configuration");
30 None
31 }
32 };
33 Self { state, rpc }
34 }
35
36 pub fn config(&self) -> &AppConfig {
37 &self.state.config
38 }
39
40 pub fn db(&self) -> &Arc<AppDatabase> {
41 &self.state.database
42 }
43
44 pub fn rpc(&self) -> Result<&SandboxRpcClient, AgentError> {
45 self.rpc.as_ref().ok_or_else(|| {
46 AgentError::RuntimeUnavailable("sandbox runtime is not configured".to_string())
47 })
48 }
49
50 pub fn rpc_opt(&self) -> Option<&SandboxRpcClient> {
51 self.rpc.as_ref()
52 }
53
54 pub fn paginate(&self, limit: Option<u64>, offset: Option<u64>) -> (u64, u64) {
55 let max_pagination = self.config().agents.max_pagination.max(1);
56 let default_pagination = self.config().agents.default_pagination.max(1);
57 (
58 limit.unwrap_or(default_pagination).clamp(1, max_pagination),
59 offset.unwrap_or(0).clamp(0, 5000),
60 )
61 }
62
63 pub async fn load_user_briefs(
64 &self,
65 uids: &[Uuid],
66 ) -> Result<HashMap<Uuid, UserDto>, AgentError> {
67 if uids.is_empty() {
68 return Ok(HashMap::new());
69 }
70 let mut set: Vec<Uuid> = uids.to_vec();
71 set.sort();
72 set.dedup();
73 let users = user::Entity::find()
74 .filter(user::Column::Uid.is_in(set))
75 .all(self.db().read())
76 .await?;
77 Ok(users
78 .into_iter()
79 .map(|u| (u.uid, UserDto::from(u)))
80 .collect())
81 }
82}
83
84#[async_trait]
85impl SessionGuard for AgentsService {
86 async fn lookup_session_with_user(
87 &self,
88 token_hash: &str,
89 ) -> Result<Option<SessionWithUser>, SessionError> {
90 session_layer::lookup_session_with_user(self.db().read(), token_hash).await
91 }
92}
Added lib/agents/src/share/authz.rs lib/agents/src/share/authz.rs +54 −0 View lib/agents/src/share/authz.rs
@@ -0,0 +1,54 @@
1use models::space::spaces;
2use space::share::authz::{MANAGE_CHANNELS, effective_permissions};
3use uuid::Uuid;
4
5use crate::errors::AgentError;
6use crate::services::service::AgentsService;
7
8pub async fn require_collection_manage(
9 service: &AgentsService,
10 space: &spaces::Model,
11 caller: Uuid,
12) -> Result<(), AgentError> {
13 if space.owner_id == caller {
14 return Ok(());
15 }
16 let perms = effective_permissions(service.db().read(), space, caller)
17 .await
18 .map_err(AgentError::from)?;
19 if perms.has(MANAGE_CHANNELS) {
20 Ok(())
21 } else {
22 Err(AgentError::Forbidden)
23 }
24}
25
26/// Whether the caller may attach the platform MCP to an agent run: only space
27/// owners or members with MANAGE_CHANNELS, and only while an MCP endpoint the
28/// sandbox runtime can reach is configured.
29pub async fn can_enable_mcp(
30 service: &AgentsService,
31 space: &spaces::Model,
32 caller: Uuid,
33) -> bool {
34 mcp_configured(service) && can_manage_mcp(service, space, caller).await
35}
36
37/// Permission half of [`can_enable_mcp`], without the configuration check.
38pub async fn can_manage_mcp(
39 service: &AgentsService,
40 space: &spaces::Model,
41 caller: Uuid,
42) -> bool {
43 if space.owner_id == caller {
44 return true;
45 }
46 match effective_permissions(service.db().read(), space, caller).await {
47 Ok(perms) => perms.has(MANAGE_CHANNELS),
48 Err(_) => false,
49 }
50}
51
52fn mcp_configured(service: &AgentsService) -> bool {
53 service.config().mcp.enabled() && !service.config().mcp.runtime_url().is_empty()
54}
Added lib/agents/src/share/guard.rs lib/agents/src/share/guard.rs +16 −0 View lib/agents/src/share/guard.rs
@@ -0,0 +1,16 @@
1use axum::extract::{Request, State};
2use axum::middleware::Next;
3use axum::response::Response;
4use session_layer::Session;
5use session_layer::share::guard::session_guard;
6
7use crate::services::service::AgentsService;
8
9pub async fn agents_guard(
10 State(service): State<AgentsService>,
11 session: Session,
12 request: Request,
13 next: Next,
14) -> Response {
15 session_guard::<AgentsService>(State(service), session, request, next).await
16}
Added lib/agents/src/share/lookup.rs lib/agents/src/share/lookup.rs +130 −0 View lib/agents/src/share/lookup.rs
@@ -0,0 +1,130 @@
1use models::agent::agent_item as agent;
2use models::agent::agents;
3use models::dir::dirs::{self, DirKind};
4use models::space::spaces;
5use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
6use space::share::authz::{READ, VIEW_SPACE, effective_permissions};
7use uuid::Uuid;
8
9use crate::errors::AgentError;
10use crate::services::service::AgentsService;
11
12pub async fn lookup_space_by_name(
13 service: &AgentsService,
14 space_name: &str,
15) -> Result<spaces::Model, AgentError> {
16 let lower = space_name.trim().to_lowercase();
17 if lower.is_empty() {
18 return Err(AgentError::NotFound);
19 }
20 spaces::Entity::find()
21 .filter(spaces::Column::LowerName.eq(&lower))
22 .filter(spaces::Column::IsActive.eq(true))
23 .one(service.db().read())
24 .await?
25 .ok_or(AgentError::NotFound)
26}
27
28pub async fn require_member(
29 service: &AgentsService,
30 space: &spaces::Model,
31 caller: Uuid,
32) -> Result<(), AgentError> {
33 let perms = effective_permissions(service.db().read(), space, caller)
34 .await
35 .map_err(AgentError::from)?;
36 if perms.has(VIEW_SPACE) || perms.has(READ) {
37 Ok(())
38 } else {
39 Err(AgentError::Forbidden)
40 }
41}
42
43pub async fn space_context(
44 service: &AgentsService,
45 caller: Uuid,
46 space_name: &str,
47) -> Result<spaces::Model, AgentError> {
48 let space = lookup_space_by_name(service, space_name).await?;
49 require_member(service, &space, caller).await?;
50 Ok(space)
51}
52
53pub async fn lookup_collection(
54 service: &AgentsService,
55 space: &spaces::Model,
56 collection_id: Uuid,
57 caller: Uuid,
58) -> Result<agents::Model, AgentError> {
59 let collection = agents::Entity::find_by_id(collection_id)
60 .one(service.db().read())
61 .await?
62 .ok_or(AgentError::NotFound)?;
63
64 let dir = dirs::Entity::find_by_id(collection.dir_id)
65 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
66 .one(service.db().read())
67 .await?
68 .ok_or(AgentError::NotFound)?;
69
70 if dir.kind != DirKind::Object {
71 return Err(AgentError::NotFound);
72 }
73
74 if !space::share::dir_visibility::is_dir_visible(
75 service.db().read(),
76 space,
77 collection.dir_id,
78 caller,
79 )
80 .await?
81 {
82 return Err(AgentError::NotFound);
83 }
84
85 Ok(collection)
86}
87
88pub async fn lookup_agent(
89 service: &AgentsService,
90 collection: &agents::Model,
91 agent_ref: &str,
92) -> Result<agent::Model, AgentError> {
93 let base = agent::Entity::find().filter(agent::Column::CollectionId.eq(collection.id));
94 let found = if let Ok(id) = Uuid::parse_str(agent_ref) {
95 base.filter(agent::Column::Id.eq(id))
96 .one(service.db().read())
97 .await?
98 } else if let Ok(number) = agent_ref.parse::<i32>() {
99 base.filter(agent::Column::AutoincrementId.eq(number))
100 .one(service.db().read())
101 .await?
102 } else {
103 None
104 };
105 found.ok_or(AgentError::NotFound)
106}
107
108pub async fn collection_context(
109 service: &AgentsService,
110 caller: Uuid,
111 space_name: &str,
112 collection_id: Uuid,
113) -> Result<(spaces::Model, agents::Model), AgentError> {
114 let space = space_context(service, caller, space_name).await?;
115 let collection = lookup_collection(service, &space, collection_id, caller).await?;
116 Ok((space, collection))
117}
118
119pub async fn agent_context(
120 service: &AgentsService,
121 caller: Uuid,
122 space_name: &str,
123 collection_id: Uuid,
124 agent_id: Uuid,
125) -> Result<(spaces::Model, agents::Model, agent::Model), AgentError> {
126 let (space, collection) =
127 collection_context(service, caller, space_name, collection_id).await?;
128 let item = lookup_agent(service, &collection, &agent_id.to_string()).await?;
129 Ok((space, collection, item))
130}
Added lib/agents/src/share/mod.rs lib/agents/src/share/mod.rs +3 −0 View lib/agents/src/share/mod.rs
@@ -0,0 +1,3 @@
1pub mod authz;
2pub mod guard;
3pub mod lookup;
Added lib/agents/src/errors.rs lib/agents/src/errors.rs +81 −0 View lib/agents/src/errors.rs
@@ -0,0 +1,81 @@
1use std::fmt;
2
3use sandbox_rpc::SandboxRpcError;
4
5#[derive(Debug)]
6pub enum AgentError {
7 BadRequest(String),
8 Unauthorized,
9 Forbidden,
10 NotFound,
11 Conflict(String),
12 RateLimited(String),
13 RuntimeUnavailable(String),
14 Internal(String),
15}
16
17impl fmt::Display for AgentError {
18 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19 match self {
20 Self::BadRequest(message) => write!(f, "{message}"),
21 Self::Unauthorized => write!(f, "unauthorized"),
22 Self::Forbidden => write!(f, "forbidden"),
23 Self::NotFound => write!(f, "not found"),
24 Self::Conflict(message) => write!(f, "{message}"),
25 Self::RateLimited(message) => write!(f, "{message}"),
26 Self::RuntimeUnavailable(message) => write!(f, "{message}"),
27 Self::Internal(message) => write!(f, "{message}"),
28 }
29 }
30}
31
32impl std::error::Error for AgentError {}
33
34export::db_error_conversions!(AgentError);
35export::export_error_conversion!(AgentError);
36export::json_payload_conversion!(AgentError);
37export::domain_error_passthrough!(AgentError from space::errors::SpaceError);
38
39impl From<SandboxRpcError> for AgentError {
40 fn from(error: SandboxRpcError) -> Self {
41 match error {
42 SandboxRpcError::Unavailable(message) => Self::RuntimeUnavailable(message),
43 SandboxRpcError::Rejected(message) => Self::BadRequest(message),
44 SandboxRpcError::Config(message) => Self::RuntimeUnavailable(message),
45 SandboxRpcError::Internal(message) => Self::Internal(message),
46 }
47 }
48}
49
50export::api_error! {
51 log: "agents",
52 pub enum ApiError {
53 BadRequest(400, "request failed validation"),
54 Unauthorized(401, "authentication required"),
55 Forbidden(403, "operation not allowed"),
56 NotFound(404, "resource not found"),
57 Conflict(409, "resource conflict"),
58 TooManyRequests(429, "rate limit exceeded"),
59 ServiceUnavailable(503, "agent runtime unavailable"),
60 Internal(500, "internal server error"),
61 }
62}
63
64impl From<AgentError> for ApiError {
65 fn from(error: AgentError) -> Self {
66 match error {
67 AgentError::BadRequest(msg) => Self::bad_request(msg),
68 AgentError::Unauthorized => Self::unauthorized(),
69 AgentError::Forbidden => Self::forbidden(),
70 AgentError::NotFound => Self::not_found("agent resource not found"),
71 AgentError::Conflict(msg) => Self::conflict(msg),
72 AgentError::RateLimited(msg) => Self::rate_limited(msg),
73 AgentError::RuntimeUnavailable(message) => Self::build(
74 503,
75 export::rest::AppResponseData::err(503, message)
76 .with_error_code("runtime_unavailable"),
77 ),
78 AgentError::Internal(message) => Self::internal(message),
79 }
80 }
81}
Added lib/agents/src/lib.rs lib/agents/src/lib.rs +6 −0 View lib/agents/src/lib.rs
@@ -0,0 +1,6 @@
1pub mod errors;
2pub mod handlers;
3pub mod openapi;
4pub mod routes;
5pub mod services;
6pub mod share;
Added lib/agents/src/openapi.rs lib/agents/src/openapi.rs +89 −0 View lib/agents/src/openapi.rs
@@ -0,0 +1,89 @@
1use export::rest::AppResponseData;
2use utoipa::OpenApi;
3
4#[derive(OpenApi)]
5#[openapi(
6 paths(
7 crate::handlers::collections::create_agent_collection,
8 crate::handlers::collections::list_agent_collections,
9 crate::handlers::collections::get_agent_collection,
10 crate::handlers::collections::update_agent_collection,
11 crate::handlers::collections::delete_agent_collection,
12 crate::handlers::agents::create_agent,
13 crate::handlers::agents::list_agents,
14 crate::handlers::agents::get_agent,
15 crate::handlers::agents::update_agent,
16 crate::handlers::agents::delete_agent,
17 crate::handlers::builder::builder_chat,
18 crate::handlers::avatar::upload_agent_avatar,
19 crate::handlers::avatar::agent_avatar,
20 crate::handlers::chat::send_agent_message,
21 crate::handlers::chat::create_agent_stream_token,
22 crate::handlers::chat::list_agent_messages,
23 crate::handlers::chat::list_agent_tool_calls,
24 crate::handlers::chat::list_agent_runs,
25 crate::handlers::chat::cancel_agent_run,
26 crate::handlers::chat::list_agent_approvals,
27 crate::handlers::chat::decide_agent_approval,
28 crate::handlers::chat::list_agent_questions,
29 crate::handlers::chat::answer_agent_questions,
30 ),
31 components(schemas(
32 AppResponseData<export::rest::NoData>,
33 AppResponseData<crate::services::collections::AgentCollectionDto>,
34 AppResponseData<crate::services::collections::AgentCollectionPage>,
35 AppResponseData<crate::services::agents::AgentDto>,
36 AppResponseData<crate::services::agents::AgentPage>,
37 AppResponseData<Vec<crate::services::chat::AgentRunDto>>,
38 AppResponseData<Vec<crate::services::chat::AgentMessageDto>>,
39 AppResponseData<Vec<crate::services::chat::AgentToolCallDto>>,
40 AppResponseData<Vec<crate::services::chat::AgentApprovalDto>>,
41 AppResponseData<Vec<crate::services::chat::AgentQuestionDto>>,
42 AppResponseData<crate::services::chat::AgentStreamTokenDto>,
43 AppResponseData<crate::services::chat::AgentRunDto>,
44 AppResponseData<crate::services::chat::AgentApprovalDto>,
45 AppResponseData<crate::services::chat::AgentQuestionDto>,
46 export::rest::NoData,
47 space::services::spaces::UserDto,
48 space::services::spaces::SpaceNamePath,
49 crate::services::collections::AgentCollectionDto,
50 crate::services::collections::AgentCollectionPage,
51 crate::services::collections::CreateAgentCollectionCmd,
52 crate::services::collections::UpdateAgentCollectionCmd,
53 crate::services::collections::ListAgentCollectionQuery,
54 crate::services::collections::AgentCollectionPath,
55 crate::services::agents::AgentDto,
56 crate::services::agents::AgentPage,
57 crate::services::agents::CreateAgentCmd,
58 crate::services::agents::UpdateAgentCmd,
59 crate::services::agents::ListAgentQuery,
60 crate::services::agents::AgentPath,
61 crate::services::chat::AgentRunDto,
62 crate::services::chat::AgentMessageDto,
63 crate::services::chat::AgentToolCallDto,
64 crate::services::chat::AgentApprovalDto,
65 crate::services::chat::AgentQuestionDto,
66 crate::services::chat::AgentQuestionItemDto,
67 crate::services::chat::AgentQuestionOptionDto,
68 crate::services::chat::AgentQuestionAnswerDto,
69 crate::services::chat::AgentStreamTokenDto,
70 crate::services::builder::BuilderMessage,
71 crate::services::builder::BuilderChatCmd,
72 crate::services::builder::BuilderDraft,
73 crate::services::builder::BuilderChatResult,
74 AppResponseData<crate::services::builder::BuilderChatResult>,
75 crate::services::chat::SendAgentMessageCmd,
76 crate::services::chat::DecideAgentApprovalCmd,
77 crate::services::chat::AnswerAgentQuestionCmd,
78 crate::services::chat::AgentQuestionAnswerCmd,
79 crate::services::chat::ListAgentMessageQuery,
80 crate::services::chat::ListAgentToolCallQuery,
81 crate::services::chat::AgentRunPath,
82 crate::services::chat::AgentApprovalPath,
83 crate::services::chat::AgentQuestionPath,
84 )),
85 tags(
86 (name = "agents", description = "Agent set and agent management API")
87 )
88)]
89pub struct AgentsApiDoc;
Added lib/agents/src/routes.rs lib/agents/src/routes.rs +87 −0 View lib/agents/src/routes.rs
@@ -0,0 +1,87 @@
1use axum::Router;
2use axum::routing::{get, post};
3
4use crate::handlers::agents::{create_agent, delete_agent, get_agent, list_agents, update_agent};
5use crate::handlers::avatar::{agent_avatar, upload_agent_avatar};
6use crate::handlers::builder::builder_chat;
7use crate::handlers::chat::{
8 answer_agent_questions, cancel_agent_run, create_agent_stream_token, decide_agent_approval,
9 list_agent_approvals, list_agent_messages, list_agent_questions, list_agent_runs,
10 list_agent_tool_calls, send_agent_message,
11};
12use crate::handlers::collections::{
13 create_agent_collection, delete_agent_collection, get_agent_collection, list_agent_collections,
14 update_agent_collection,
15};
16use crate::services::service::AgentsService;
17use crate::share::guard::agents_guard;
18
19pub fn router(service: AgentsService) -> Router<AgentsService> {
20 Router::new()
21 .route(
22 "/agents/{space_name}/collections",
23 post(create_agent_collection).get(list_agent_collections),
24 )
25 .route(
26 "/agents/{space_name}/collections/{collection_id}",
27 get(get_agent_collection)
28 .patch(update_agent_collection)
29 .delete(delete_agent_collection),
30 )
31 .route(
32 "/agents/{space_name}/collections/{collection_id}/agents",
33 post(create_agent).get(list_agents),
34 )
35 .route(
36 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}",
37 get(get_agent).patch(update_agent).delete(delete_agent),
38 )
39 .route(
40 "/agents/{space_name}/collections/{collection_id}/builder",
41 post(builder_chat),
42 )
43 .route(
44 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/avatar",
45 get(agent_avatar).put(upload_agent_avatar),
46 )
47 .route(
48 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/stream-token",
49 post(create_agent_stream_token),
50 )
51 .route(
52 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/messages",
53 post(send_agent_message).get(list_agent_messages),
54 )
55 .route(
56 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/tool-calls",
57 get(list_agent_tool_calls),
58 )
59 .route(
60 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs",
61 get(list_agent_runs),
62 )
63 .route(
64 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs/{run_id}/cancel",
65 post(cancel_agent_run),
66 )
67 .route(
68 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals",
69 get(list_agent_approvals),
70 )
71 .route(
72 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals/{approval_id}/decide",
73 post(decide_agent_approval),
74 )
75 .route(
76 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions",
77 get(list_agent_questions),
78 )
79 .route(
80 "/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions/{question_id}/answer",
81 post(answer_agent_questions),
82 )
83 .route_layer(axum::middleware::from_fn_with_state(
84 service.clone(),
85 agents_guard,
86 ))
87}
Added lib/agents/Cargo.toml lib/agents/Cargo.toml +24 −0 View lib/agents/Cargo.toml
@@ -0,0 +1,24 @@
1[package]
2name = "agents"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7agent = { workspace = true }
8async-trait = { workspace = true }
9axum = { workspace = true }
10config = { workspace = true }
11export = { workspace = true }
12git = { workspace = true }
13mcp-api = { workspace = true }
14models = { workspace = true }
15sandbox-rpc = { workspace = true }
16sea-orm = { workspace = true }
17serde = { workspace = true, features = ["derive"] }
18serde_json = { workspace = true }
19session-layer = { workspace = true }
20space = { workspace = true }
21time = { workspace = true }
22tracing = { workspace = true }
23utoipa = { workspace = true }
24uuid = { workspace = true }
No newline at end of file
Added lib/config/src/agents.rs lib/config/src/agents.rs +42 −0 View lib/config/src/agents.rs
@@ -0,0 +1,42 @@
1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4#[serde(default)]
5pub struct AgentsConfig {
6 pub max_pagination: u64,
7 pub default_pagination: u64,
8 pub max_collection_name_len: usize,
9 pub max_agent_name_len: usize,
10 pub max_instructions_len: usize,
11 /// Lifetime of the per-run space token attached to agent runs that enable
12 /// the platform MCP (seconds, clamped by mcp_api::space_token).
13 pub mcp_token_ttl_secs: u64,
14}
15
16impl Default for AgentsConfig {
17 fn default() -> Self {
18 Self {
19 max_pagination: 100,
20 default_pagination: 20,
21 max_collection_name_len: 100,
22 max_agent_name_len: 100,
23 max_instructions_len: 32_768,
24 mcp_token_ttl_secs: 86_400,
25 }
26 }
27}
28
29impl AgentsConfig {
30 pub fn validate(&self) -> Result<(), String> {
31 if self.max_pagination == 0 || self.default_pagination == 0 {
32 return Err("agents pagination limits must be greater than 0".to_string());
33 }
34 if self.max_collection_name_len == 0 || self.max_agent_name_len == 0 {
35 return Err("agents name length limits must be greater than 0".to_string());
36 }
37 if self.mcp_token_ttl_secs == 0 {
38 return Err("agents.mcp_token_ttl_secs must be greater than 0".to_string());
39 }
40 Ok(())
41 }
42}
Added lib/config/src/sandbox_rpc.rs lib/config/src/sandbox_rpc.rs +72 −0 View lib/config/src/sandbox_rpc.rs
@@ -0,0 +1,72 @@
1use serde::{Deserialize, Serialize};
2
3/// gRPC bridge to the standalone `sandbox-agent` runtime. The main workspace
4/// never links the runtime; it only reaches it through this endpoint.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6#[serde(default)]
7pub struct SandboxRpcConfig {
8 pub enabled: bool,
9 pub endpoint: String,
10 /// HTTP base of the runtime's fanout service (SSE events/tokens). Empty
11 /// disables streaming; the web UI then falls back to polling.
12 pub fanout_endpoint: String,
13 pub trusted_key: String,
14 pub request_timeout_secs: u64,
15 /// Lifetime of the JWT minted per SSE connection for the fanout streams.
16 pub stream_token_ttl_secs: u64,
17}
18
19impl Default for SandboxRpcConfig {
20 fn default() -> Self {
21 Self {
22 enabled: false,
23 endpoint: "http://127.0.0.1:8091".to_string(),
24 fanout_endpoint: "http://127.0.0.1:8090".to_string(),
25 trusted_key: String::new(),
26 request_timeout_secs: 30,
27 stream_token_ttl_secs: 600,
28 }
29 }
30}
31
32impl SandboxRpcConfig {
33 pub fn is_valid(&self) -> Result<(), String> {
34 if !self.enabled {
35 return Ok(());
36 }
37 validate_endpoint("sandbox_rpc.endpoint", &self.endpoint, false)?;
38 if !self.fanout_endpoint.trim().is_empty() {
39 // The fanout base is browser-facing: an origin-relative prefix
40 // (e.g. `/sandbox` behind a reverse proxy) is the CORS-free default.
41 validate_endpoint("sandbox_rpc.fanout_endpoint", &self.fanout_endpoint, true)?;
42 }
43 if self.trusted_key.trim().is_empty() {
44 return Err("sandbox_rpc.trusted_key must not be empty when enabled".to_string());
45 }
46 if self.request_timeout_secs == 0 {
47 return Err("sandbox_rpc.request_timeout_secs must be greater than 0".to_string());
48 }
49 if self.stream_token_ttl_secs == 0 {
50 return Err("sandbox_rpc.stream_token_ttl_secs must be greater than 0".to_string());
51 }
52 Ok(())
53 }
54}
55
56fn validate_endpoint(field: &str, raw: &str, allow_relative: bool) -> Result<(), String> {
57 let endpoint = raw.trim();
58 if endpoint.is_empty() {
59 return Err(format!("{field} must not be empty"));
60 }
61 if allow_relative && endpoint.starts_with('/') {
62 if endpoint.starts_with("//") {
63 return Err(format!("{field} must not be protocol-relative"));
64 }
65 return Ok(());
66 }
67 let url = url::Url::parse(endpoint).map_err(|e| format!("{field} is not a valid URL: {e}"))?;
68 if url.scheme() != "http" && url.scheme() != "https" {
69 return Err(format!("{field} must use http or https"));
70 }
71 Ok(())
72}
Modified lib/config/src/scoped.rs lib/config/src/scoped.rs +11 −0 View lib/config/src/scoped.rs
@@ -2,6 +2,7 @@
22use serde::de::DeserializeOwned;
33
44use crate::admin::AdminConfig as AdminSection;
5use crate::agents::AgentsConfig;
56use crate::ai::AiConfig;
67use crate::aihost::AiHostServerConfig;
78use crate::apihost::ApihostConfig as ApihostSection;
@@ -25,6 +26,7 @@
2526use crate::quota::QuotaConfig;
2627use crate::redis::RedisConfig;
2728use crate::s3::S3Config;
29use crate::sandbox_rpc::SandboxRpcConfig;
2830use crate::search::SearchConfig;
2931use crate::session::SessionConfig;
3032use crate::space::SpaceConfig;
@@ -200,6 +202,9 @@
200202 pub quota: QuotaConfig,
201203 pub space: SpaceConfig,
202204 pub issues: IssueConfig,
205 pub agents: AgentsConfig,
206 pub sandbox_rpc: SandboxRpcConfig,
207 pub mcp: McpConfig,
203208 pub git: GitConfig,
204209 pub email: EmailConfig,
205210 pub email_queue: EmailQueueConfig,
@@ -228,6 +233,9 @@
228233 quota: self.quota,
229234 space: self.space,
230235 issues: self.issues,
236 agents: self.agents,
237 sandbox_rpc: self.sandbox_rpc,
238 mcp: self.mcp,
231239 git: self.git,
232240 email: self.email,
233241 email_queue: self.email_queue,
@@ -250,6 +258,7 @@
250258 self.pow.validate()?;
251259 self.auth.validate()?;
252260 self.chat.is_valid()?;
261 self.mcp.is_valid()?;
253262 self.email.is_valid()?;
254263 self.email_queue.is_valid()?;
255264 self.session.is_valid()?;
@@ -258,6 +267,8 @@
258267 self.apihost.is_valid()?;
259268 self.ai.validate()?;
260269 self.livekit.is_valid()?;
270 self.agents.validate()?;
271 self.sandbox_rpc.is_valid()?;
261272 self.push.is_valid()
262273 }
263274}
Added lib/models/src/agent/agent_item.rs lib/models/src/agent/agent_item.rs +34 −0 View lib/models/src/agent/agent_item.rs
@@ -0,0 +1,34 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "agent")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(unique_key = "collection_number")]
12 pub collection_id: Uuid,
13 #[sea_orm(unique_key = "collection_number")]
14 pub autoincrement_id: i32,
15 pub author_id: Uuid,
16 pub name: String,
17 pub description: Option<String>,
18 pub instructions: Option<String>,
19 pub avatar_url: Option<String>,
20 #[sea_orm(column_type = "JsonBinary")]
21 pub starters: Option<sea_orm::JsonValue>,
22 pub conversation_id: Option<String>,
23 pub last_run_id: Option<String>,
24 #[sea_orm(default_value = "false")]
25 pub is_archived: bool,
26 pub created_at: OffsetDateTime,
27 pub updated_at: Option<OffsetDateTime>,
28 #[sea_orm(belongs_to, from = "collection_id", to = "id")]
29 pub collection: BelongsTo<super::agents::Entity>,
30 #[sea_orm(belongs_to, from = "author_id", to = "uid")]
31 pub author: BelongsTo<super::super::users::user::Entity>,
32}
33
34impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/agent/agents.rs lib/models/src/agent/agents.rs +22 −0 View lib/models/src/agent/agents.rs
@@ -0,0 +1,22 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "agents")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(indexed)]
12 pub dir_id: Uuid,
13 pub name: String,
14 pub icon: Option<String>,
15 pub description: Option<String>,
16 pub created_at: OffsetDateTime,
17 pub updated_at: Option<OffsetDateTime>,
18 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
19 pub dir: BelongsTo<super::super::dir::dirs::Entity>,
20}
21
22impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/agent/mod.rs lib/models/src/agent/mod.rs +2 −0 View lib/models/src/agent/mod.rs
@@ -0,0 +1,2 @@
1pub mod agent_item;
2pub mod agents;
Modified lib/models/src/lib.rs lib/models/src/lib.rs +1 −0 View lib/models/src/lib.rs
@@ -1,3 +1,4 @@
1pub mod agent;
12pub mod ai;
23pub mod chat;
34pub mod ci;
Added lib/sandbox-rpc/proto/agent.proto lib/sandbox-rpc/proto/agent.proto +318 −0 View lib/sandbox-rpc/proto/agent.proto
@@ -0,0 +1,318 @@
1syntax = "proto3";
2
3package sandbox.agent.v1;
4
5service Agent {
6 rpc Health(HealthRequest) returns (HealthResponse);
7 rpc IssueToken(IssueTokenRequest) returns (IssueTokenResponse);
8
9 rpc CreateSession(CreateSessionRequest) returns (Session);
10 rpc GetSession(SessionRef) returns (Session);
11 rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse);
12 rpc ArchiveSession(SessionRef) returns (Session);
13 rpc DeleteSession(SessionRef) returns (DeleteResponse);
14
15 rpc SendMessage(SendMessageRequest) returns (Run);
16 rpc GetRun(RunRef) returns (Run);
17 rpc ListRuns(SessionRef) returns (ListRunsResponse);
18 rpc CancelRun(RunRef) returns (Run);
19
20 rpc ListMessages(ListMessagesRequest) returns (ListMessagesResponse);
21 rpc ListToolCalls(ListToolCallsRequest) returns (ListToolCallsResponse);
22 rpc ListApprovals(SessionRef) returns (ListApprovalsResponse);
23 rpc DecideApproval(DecideApprovalRequest) returns (Approval);
24
25 rpc ListQuestions(SessionRef) returns (ListQuestionsResponse);
26 rpc AnswerQuestions(AnswerQuestionsRequest) returns (Question);
27}
28
29message HealthRequest {}
30
31message HealthResponse {
32 string version = 1;
33 bool database = 2;
34 bool nats = 3;
35 bool storage = 4;
36}
37
38message IssueTokenRequest {
39 string space = 1;
40 optional string user = 2;
41 repeated string scopes = 3;
42 optional uint64 ttl_secs = 4;
43}
44
45message IssueTokenResponse {
46 string token = 1;
47 int64 expires_at = 2;
48}
49
50message CompactionSpec {
51 reserved 1;
52 optional double trigger_ratio = 2;
53 optional uint64 trigger_tokens = 3;
54 optional uint64 keep_recent = 4;
55 optional uint64 min_messages = 5;
56 optional uint64 max_summary_tokens = 6;
57}
58
59message HookSpec {
60 string event = 1;
61 string script = 2;
62 optional uint64 timeout_ms = 3;
63 optional bool blocking = 4;
64 optional string cwd = 5;
65 map<string, string> env = 6;
66}
67
68message McpServerSpecMessage {
69 string name = 1;
70 string url = 2;
71 optional string description = 3;
72 map<string, string> headers = 4;
73 optional string tool_prefix = 5;
74}
75
76message AgentSpecMessage {
77 optional string name = 1;
78 optional string description = 2;
79 optional string instructions = 3;
80 optional string provider = 4;
81 optional string model = 5;
82 optional string space = 6;
83 repeated string scope = 7;
84 optional double temperature = 8;
85 optional double top_p = 9;
86 optional uint64 max_tokens = 10;
87 optional string reasoning_effort = 11;
88 optional string user_id = 12;
89 optional uint64 max_turns = 13;
90 optional int64 max_iterations = 14;
91 optional uint64 history_limit = 15;
92 optional string follow_up = 16;
93 repeated string approval_required = 17;
94 optional uint64 approval_timeout_ms = 18;
95 optional uint64 context_window = 19;
96 optional CompactionSpec compaction = 20;
97 optional bool judge_enabled = 21;
98 optional string judge_model = 22;
99 repeated HookSpec hooks = 23;
100 optional SubagentSpec subagents = 24;
101}
102
103message SubagentProfile {
104 optional string model = 1;
105 repeated string mcp_tools = 2;
106}
107
108message SubagentSpec {
109 optional bool enabled = 1;
110 optional uint64 max_tasks = 2;
111 optional uint64 concurrency = 3;
112 optional uint64 max_turns = 4;
113 optional uint64 timeout_ms = 5;
114 optional uint64 token_budget = 6;
115 optional uint64 max_report_bytes = 7;
116 optional SubagentProfile executor = 8;
117 optional SubagentProfile explorer = 9;
118 optional SubagentProfile inspector = 10;
119}
120
121message CreateSessionRequest {
122 string space = 1;
123 optional string user = 2;
124 optional string name = 3;
125 optional AgentSpecMessage spec = 4;
126}
127
128message SessionRef {
129 string space = 1;
130 optional string user = 2;
131 string conversation_id = 3;
132}
133
134message Session {
135 string conversation_id = 1;
136 string name = 2;
137 string space = 3;
138 optional string user = 4;
139 string status = 5;
140 bool live = 6;
141 int64 created_at = 7;
142 int64 updated_at = 8;
143}
144
145message ListSessionsRequest {
146 string space = 1;
147}
148
149message ListSessionsResponse {
150 repeated Session sessions = 1;
151}
152
153message DeleteResponse {
154 bool deleted = 1;
155}
156
157message SendMessageRequest {
158 string space = 1;
159 optional string user = 2;
160 string conversation_id = 3;
161 string text = 4;
162 optional bool stream = 5;
163 optional uint64 max_turns = 6;
164 optional uint64 history_limit = 7;
165 optional string goal_id = 8;
166 repeated HookSpec hooks = 9;
167 repeated McpServerSpecMessage mcp_servers = 10;
168 optional string instructions_suffix = 12;
169}
170
171message RunRef {
172 string space = 1;
173 optional string user = 2;
174 string run_id = 3;
175}
176
177message Run {
178 string run_id = 1;
179 string conversation_id = 2;
180 string status = 3;
181 int64 input_tokens = 4;
182 int64 output_tokens = 5;
183 int64 total_tokens = 6;
184 optional string output = 7;
185 optional string error = 8;
186 int64 created_at = 9;
187 optional int64 finished_at = 10;
188 optional string parent_run_id = 11;
189 optional string agent_role = 12;
190 optional string task_id = 13;
191 int64 tree_input_tokens = 14;
192 int64 tree_output_tokens = 15;
193 int64 tree_total_tokens = 16;
194}
195
196message ListRunsResponse {
197 repeated Run runs = 1;
198}
199
200message Message {
201 string message_id = 1;
202 string conversation_id = 2;
203 optional string run_id = 3;
204 string role = 4;
205 string kind = 5;
206 string status = 6;
207 string content = 7;
208 int64 sequence = 8;
209 int64 created_at = 9;
210 optional string external_call_id = 10;
211}
212
213message ListMessagesRequest {
214 string space = 1;
215 optional string user = 2;
216 string conversation_id = 3;
217 optional int64 after_sequence = 4;
218 optional uint64 limit = 5;
219 optional string run_id = 6;
220}
221
222message ListMessagesResponse {
223 repeated Message messages = 1;
224}
225
226message ListToolCallsRequest {
227 string space = 1;
228 optional string user = 2;
229 string conversation_id = 3;
230 optional uint64 limit = 4;
231}
232
233message ToolCall {
234 string tool_call_id = 1;
235 string conversation_id = 2;
236 string run_id = 3;
237 string call_id = 4;
238 string name = 5;
239 string kind = 6;
240 string status = 7;
241 optional string input = 8;
242 optional int64 duration_ms = 9;
243 int64 created_at = 10;
244 optional int64 finished_at = 11;
245}
246
247message ListToolCallsResponse {
248 repeated ToolCall tool_calls = 1;
249}
250
251message Approval {
252 string approval_id = 1;
253 string run_id = 2;
254 string conversation_id = 3;
255 string action = 4;
256 optional string tool_name = 5;
257 string status = 6;
258 string scope = 7;
259 optional string reason = 8;
260 optional string decision_reason = 9;
261 int64 requested_at = 10;
262 optional int64 decided_at = 11;
263}
264
265message ListApprovalsResponse {
266 repeated Approval approvals = 1;
267}
268
269message DecideApprovalRequest {
270 string space = 1;
271 optional string user = 2;
272 string approval_id = 3;
273 string decision = 4;
274 optional string reason = 5;
275 optional string scope = 6;
276}
277
278message QuestionOption {
279 string label = 1;
280 optional string description = 2;
281}
282
283message QuestionItem {
284 string item_id = 1;
285 string question = 2;
286 optional string header = 3;
287 repeated QuestionOption options = 4;
288 bool multiple = 5;
289}
290
291message QuestionAnswer {
292 string item_id = 1;
293 repeated string selected = 2;
294 optional string text = 3;
295}
296
297message Question {
298 string question_id = 1;
299 string run_id = 2;
300 string conversation_id = 3;
301 string status = 4;
302 repeated QuestionItem questions = 5;
303 repeated QuestionAnswer answers = 6;
304 optional string answered_by = 7;
305 int64 requested_at = 8;
306 optional int64 answered_at = 9;
307}
308
309message ListQuestionsResponse {
310 repeated Question questions = 1;
311}
312
313message AnswerQuestionsRequest {
314 string space = 1;
315 optional string user = 2;
316 string question_id = 3;
317 repeated QuestionAnswer answers = 4;
318}
Added lib/sandbox-rpc/src/lib.rs lib/sandbox-rpc/src/lib.rs +345 −0 View lib/sandbox-rpc/src/lib.rs
@@ -0,0 +1,345 @@
1pub mod proto {
2 tonic::include_proto!("sandbox.agent.v1");
3}
4
5use std::time::Duration;
6
7use config::sandbox_rpc::SandboxRpcConfig;
8use tonic::metadata::{Ascii, MetadataValue};
9use tonic::service::Interceptor;
10use tonic::service::interceptor::InterceptedService;
11use tonic::transport::{Channel, Endpoint};
12use tonic::{Request, Status};
13
14pub const TRUSTED_KEY_HEADER: &str = "x-trusted-key";
15pub const SCOPE_STREAM: &str = "stream";
16
17#[derive(Debug, thiserror::Error)]
18pub enum SandboxRpcError {
19 #[error("sandbox runtime is unavailable: {0}")]
20 Unavailable(String),
21 #[error("sandbox runtime rejected the request: {0}")]
22 Rejected(String),
23 #[error("sandbox runtime configuration is invalid: {0}")]
24 Config(String),
25 #[error("sandbox runtime request failed: {0}")]
26 Internal(String),
27}
28
29impl SandboxRpcError {
30 fn from_status(status: Status) -> Self {
31 match status.code() {
32 tonic::Code::Unavailable | tonic::Code::DeadlineExceeded => {
33 Self::Unavailable(status.message().to_string())
34 }
35 tonic::Code::Unauthenticated
36 | tonic::Code::PermissionDenied
37 | tonic::Code::InvalidArgument
38 | tonic::Code::NotFound
39 | tonic::Code::FailedPrecondition
40 | tonic::Code::AlreadyExists
41 | tonic::Code::ResourceExhausted => Self::Rejected(status.message().to_string()),
42 _ => Self::Internal(status.message().to_string()),
43 }
44 }
45}
46
47#[derive(Clone)]
48struct TrustedKey {
49 value: MetadataValue<Ascii>,
50}
51
52impl Interceptor for TrustedKey {
53 fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
54 request
55 .metadata_mut()
56 .insert(TRUSTED_KEY_HEADER, self.value.clone());
57 Ok(request)
58 }
59}
60
61type InnerClient = proto::agent_client::AgentClient<InterceptedService<Channel, TrustedKey>>;
62
63/// gRPC bridge to the standalone `sandbox-agent` runtime. Nothing in this
64/// workspace links the runtime; every call goes over the wire with the
65/// configured trusted key.
66#[derive(Clone)]
67pub struct SandboxRpcClient {
68 inner: InnerClient,
69 fanout_endpoint: String,
70 stream_token_ttl_secs: u64,
71}
72
73impl SandboxRpcClient {
74 pub fn from_config(config: &SandboxRpcConfig) -> Result<Option<Self>, SandboxRpcError> {
75 if !config.enabled {
76 return Ok(None);
77 }
78 config.is_valid().map_err(SandboxRpcError::Config)?;
79 let value = MetadataValue::try_from(config.trusted_key.trim()).map_err(|error| {
80 SandboxRpcError::Config(format!("trusted_key is not a valid header value: {error}"))
81 })?;
82 let channel = Endpoint::from_shared(config.endpoint.trim().to_string())
83 .map_err(|error| SandboxRpcError::Config(format!("endpoint is invalid: {error}")))?
84 .connect_timeout(Duration::from_secs(5))
85 .timeout(Duration::from_secs(config.request_timeout_secs.max(1)))
86 .connect_lazy();
87 Ok(Some(Self {
88 inner: proto::agent_client::AgentClient::with_interceptor(
89 channel,
90 TrustedKey { value },
91 ),
92 fanout_endpoint: config.fanout_endpoint.trim().to_string(),
93 stream_token_ttl_secs: config.stream_token_ttl_secs,
94 }))
95 }
96
97 /// Browser-facing base of the runtime's fanout service. The stream token
98 /// response hands this to the web client, which connects directly.
99 pub fn fanout_endpoint(&self) -> &str {
100 &self.fanout_endpoint
101 }
102
103 pub fn stream_token_ttl_secs(&self) -> u64 {
104 self.stream_token_ttl_secs
105 }
106
107 fn client(&self) -> InnerClient {
108 self.inner.clone()
109 }
110
111 fn session_ref(space: &str, user: &str, conversation_id: &str) -> proto::SessionRef {
112 proto::SessionRef {
113 space: space.to_string(),
114 user: Some(user.to_string()),
115 conversation_id: conversation_id.to_string(),
116 }
117 }
118
119 pub async fn health(&self) -> Result<proto::HealthResponse, SandboxRpcError> {
120 self.client()
121 .health(proto::HealthRequest {})
122 .await
123 .map(|response| response.into_inner())
124 .map_err(SandboxRpcError::from_status)
125 }
126
127 pub async fn issue_token(
128 &self,
129 space: &str,
130 user: &str,
131 scopes: Vec<String>,
132 ttl_secs: Option<u64>,
133 ) -> Result<proto::IssueTokenResponse, SandboxRpcError> {
134 self.client()
135 .issue_token(proto::IssueTokenRequest {
136 space: space.to_string(),
137 user: Some(user.to_string()),
138 scopes,
139 ttl_secs,
140 })
141 .await
142 .map(|response| response.into_inner())
143 .map_err(SandboxRpcError::from_status)
144 }
145
146 pub async fn create_session(
147 &self,
148 space: &str,
149 user: &str,
150 name: &str,
151 spec: proto::AgentSpecMessage,
152 ) -> Result<proto::Session, SandboxRpcError> {
153 self.client()
154 .create_session(proto::CreateSessionRequest {
155 space: space.to_string(),
156 user: Some(user.to_string()),
157 name: Some(name.to_string()),
158 spec: Some(spec),
159 })
160 .await
161 .map(|response| response.into_inner())
162 .map_err(SandboxRpcError::from_status)
163 }
164
165 pub async fn delete_session(
166 &self,
167 space: &str,
168 user: &str,
169 conversation_id: &str,
170 ) -> Result<bool, SandboxRpcError> {
171 self.client()
172 .delete_session(Self::session_ref(space, user, conversation_id))
173 .await
174 .map(|response| response.into_inner().deleted)
175 .map_err(SandboxRpcError::from_status)
176 }
177
178 pub async fn send_message(
179 &self,
180 space: &str,
181 user: &str,
182 conversation_id: &str,
183 text: &str,
184 mcp_servers: Vec<proto::McpServerSpecMessage>,
185 instructions_suffix: Option<String>,
186 ) -> Result<proto::Run, SandboxRpcError> {
187 self.client()
188 .send_message(proto::SendMessageRequest {
189 space: space.to_string(),
190 user: Some(user.to_string()),
191 conversation_id: conversation_id.to_string(),
192 text: text.to_string(),
193 stream: Some(true),
194 max_turns: None,
195 history_limit: None,
196 goal_id: None,
197 hooks: Vec::new(),
198 mcp_servers,
199 instructions_suffix,
200 })
201 .await
202 .map(|response| response.into_inner())
203 .map_err(SandboxRpcError::from_status)
204 }
205
206 pub async fn list_messages(
207 &self,
208 space: &str,
209 user: &str,
210 conversation_id: &str,
211 after_sequence: Option<i64>,
212 limit: Option<u64>,
213 run_id: Option<String>,
214 ) -> Result<Vec<proto::Message>, SandboxRpcError> {
215 self.client()
216 .list_messages(proto::ListMessagesRequest {
217 space: space.to_string(),
218 user: Some(user.to_string()),
219 conversation_id: conversation_id.to_string(),
220 after_sequence,
221 limit,
222 run_id,
223 })
224 .await
225 .map(|response| response.into_inner().messages)
226 .map_err(SandboxRpcError::from_status)
227 }
228
229 pub async fn list_tool_calls(
230 &self,
231 space: &str,
232 user: &str,
233 conversation_id: &str,
234 limit: Option<u64>,
235 ) -> Result<Vec<proto::ToolCall>, SandboxRpcError> {
236 self.client()
237 .list_tool_calls(proto::ListToolCallsRequest {
238 space: space.to_string(),
239 user: Some(user.to_string()),
240 conversation_id: conversation_id.to_string(),
241 limit,
242 })
243 .await
244 .map(|response| response.into_inner().tool_calls)
245 .map_err(SandboxRpcError::from_status)
246 }
247
248 pub async fn list_runs(
249 &self,
250 space: &str,
251 user: &str,
252 conversation_id: &str,
253 ) -> Result<Vec<proto::Run>, SandboxRpcError> {
254 self.client()
255 .list_runs(Self::session_ref(space, user, conversation_id))
256 .await
257 .map(|response| response.into_inner().runs)
258 .map_err(SandboxRpcError::from_status)
259 }
260
261 pub async fn cancel_run(
262 &self,
263 space: &str,
264 user: &str,
265 run_id: &str,
266 ) -> Result<proto::Run, SandboxRpcError> {
267 self.client()
268 .cancel_run(proto::RunRef {
269 space: space.to_string(),
270 user: Some(user.to_string()),
271 run_id: run_id.to_string(),
272 })
273 .await
274 .map(|response| response.into_inner())
275 .map_err(SandboxRpcError::from_status)
276 }
277
278 pub async fn list_approvals(
279 &self,
280 space: &str,
281 user: &str,
282 conversation_id: &str,
283 ) -> Result<Vec<proto::Approval>, SandboxRpcError> {
284 self.client()
285 .list_approvals(Self::session_ref(space, user, conversation_id))
286 .await
287 .map(|response| response.into_inner().approvals)
288 .map_err(SandboxRpcError::from_status)
289 }
290
291 pub async fn decide_approval(
292 &self,
293 space: &str,
294 user: &str,
295 approval_id: &str,
296 decision: &str,
297 reason: Option<String>,
298 scope: Option<String>,
299 ) -> Result<proto::Approval, SandboxRpcError> {
300 self.client()
301 .decide_approval(proto::DecideApprovalRequest {
302 space: space.to_string(),
303 user: Some(user.to_string()),
304 approval_id: approval_id.to_string(),
305 decision: decision.to_string(),
306 reason,
307 scope,
308 })
309 .await
310 .map(|response| response.into_inner())
311 .map_err(SandboxRpcError::from_status)
312 }
313
314 pub async fn list_questions(
315 &self,
316 space: &str,
317 user: &str,
318 conversation_id: &str,
319 ) -> Result<Vec<proto::Question>, SandboxRpcError> {
320 self.client()
321 .list_questions(Self::session_ref(space, user, conversation_id))
322 .await
323 .map(|response| response.into_inner().questions)
324 .map_err(SandboxRpcError::from_status)
325 }
326
327 pub async fn answer_questions(
328 &self,
329 space: &str,
330 user: &str,
331 question_id: &str,
332 answers: Vec<proto::QuestionAnswer>,
333 ) -> Result<proto::Question, SandboxRpcError> {
334 self.client()
335 .answer_questions(proto::AnswerQuestionsRequest {
336 space: space.to_string(),
337 user: Some(user.to_string()),
338 question_id: question_id.to_string(),
339 answers,
340 })
341 .await
342 .map(|response| response.into_inner())
343 .map_err(SandboxRpcError::from_status)
344 }
345}
Added lib/sandbox-rpc/build.rs lib/sandbox-rpc/build.rs +8 −0 View lib/sandbox-rpc/build.rs
@@ -0,0 +1,8 @@
1fn main() -> Result<(), Box<dyn std::error::Error>> {
2 println!("cargo:rerun-if-changed=proto");
3 tonic_prost_build::configure()
4 .build_server(false)
5 .build_client(true)
6 .compile_protos(&["proto/agent.proto"], &["proto"])?;
7 Ok(())
8}
Added lib/sandbox-rpc/Cargo.toml lib/sandbox-rpc/Cargo.toml +15 −0 View lib/sandbox-rpc/Cargo.toml
@@ -0,0 +1,15 @@
1[package]
2name = "sandbox-rpc"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7config = { workspace = true }
8prost = { workspace = true }
9thiserror = { workspace = true }
10tonic = { workspace = true }
11tonic-prost = { workspace = true }
12tracing = { workspace = true }
13
14[build-dependencies]
15tonic-prost-build = { workspace = true }
No newline at end of file
Added lib/webapi/src/cleanup.rs lib/webapi/src/cleanup.rs +708 −0 View lib/webapi/src/cleanup.rs
@@ -0,0 +1,708 @@
1/**
2 * 空间「对象清理」:仅空间所有者可见的危险区。
3 *
4 * 空间对象分布在两棵树里 —— `dirs`(目录 / 仓库 / 问题集 / 智能体集)与
5 * `rooms`(文字、语音、舞台等频道)。这里把它们拍成一张扁平清单,并提供
6 * 「删除 / 归档 / 可见性 / 清除可见性覆盖」操作。删除复用各模块自己的清理
7 * 逻辑(问题集连同 issue 与目录节点一起删、智能体集连同会话一起删、频道软删),
8 * 纯目录节点只有为空时才允许删除,避免留下孤儿对象。
9 */
10use std::collections::HashMap;
11
12use axum::extract::{Path, State};
13use axum::routing::{delete, get, post};
14use axum::{Json, Router};
15use export::rest::{AppResponseData, NoData};
16use models::agent::agents as agent_collections;
17use models::dir::{dir_overwrite_group, dir_overwrite_users, dirs};
18use models::issue::issues as boards;
19use models::repo::repos;
20use models::room::rooms;
21use sea_orm::{
22 ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter,
23 TransactionTrait,
24};
25use serde::{Deserialize, Serialize};
26use session_layer::Session;
27use session_layer::share::session::require_uid;
28use utoipa::{OpenApi, ToSchema};
29use uuid::Uuid;
30
31use agents::services::service::AgentsService;
32use git::service::GitService;
33use issues::services::service::IssueService;
34use space::errors::SpaceError;
35use space::services::service::SpaceService;
36use space::share::lookup::lookup_space_by_name;
37
38export::api_error! {
39 log: "cleanup",
40 pub enum ApiError {
41 BadRequest(400, "request failed validation"),
42 Unauthorized(401, "authentication required"),
43 Forbidden(403, "operation not allowed"),
44 NotFound(404, "resource not found"),
45 Internal(500, "internal server error"),
46 }
47}
48
49#[derive(Clone)]
50pub struct CleanupState {
51 pub space: SpaceService,
52 pub issues: IssueService,
53 pub agents: AgentsService,
54 pub git: GitService,
55}
56
57#[derive(Debug, Clone, Serialize, ToSchema)]
58pub struct CleanupObjectDto {
59 /// `dirs.id`(目录/仓库/问题集/智能体集)或 `rooms.id`(频道)。
60 pub id: Uuid,
61 /// `dir | object | repo | board | collection | text | voice | stage | category | news | guild_room`
62 pub kind: String,
63 pub name: String,
64 /// 父链拼出的可读路径,例如 `bots / 研究研究`。
65 pub path: String,
66 /// 仅仓库与频道有可见性开关。
67 pub is_private: Option<bool>,
68 pub is_archived: bool,
69 /// 目录的子节点数 / 频道的子频道数。
70 pub child_count: i64,
71 /// 目录上的权限覆盖条数(group + user)。
72 pub overwrite_count: i64,
73}
74
75#[derive(Debug, Clone, Deserialize, ToSchema)]
76pub struct SetCleanupVisibilityCmd {
77 pub is_private: bool,
78}
79
80#[derive(Debug, Clone, Deserialize, ToSchema)]
81pub struct SetRepoArchivedCmd {
82 pub archived: bool,
83}
84
85pub fn router(
86 space: SpaceService,
87 issues: IssueService,
88 agents: AgentsService,
89 git: GitService,
90) -> Router {
91 let state = CleanupState {
92 space,
93 issues,
94 agents,
95 git,
96 };
97 Router::new()
98 .route("/spaces/{space_name}/cleanup", get(list_cleanup_objects))
99 .route(
100 "/spaces/{space_name}/cleanup/{kind}/{object_id}/visibility",
101 post(set_cleanup_visibility),
102 )
103 .route(
104 "/spaces/{space_name}/cleanup/{kind}/{object_id}/archive",
105 post(set_cleanup_archive),
106 )
107 .route(
108 "/spaces/{space_name}/cleanup/{kind}/{object_id}/overwrites",
109 delete(clear_cleanup_overwrites),
110 )
111 .route(
112 "/spaces/{space_name}/cleanup/{kind}/{object_id}",
113 delete(delete_cleanup_object),
114 )
115 .with_state(state)
116}
117
118fn map_space(error: SpaceError) -> ApiError {
119 match error {
120 SpaceError::BadRequest(message) => ApiError::bad_request(message),
121 SpaceError::Unauthorized => ApiError::unauthorized(),
122 SpaceError::Forbidden => ApiError::forbidden(),
123 SpaceError::NotFound => ApiError::not_found("resource not found"),
124 other => ApiError::internal(other.to_string()),
125 }
126}
127
128fn map_git(error: git::share::errors::GitError) -> ApiError {
129 use git::share::errors::GitError;
130 match error {
131 GitError::InvalidArgument(message) | GitError::Conflict(message) => {
132 ApiError::bad_request(message)
133 }
134 GitError::NotFound => ApiError::not_found("resource not found"),
135 GitError::Unauthorized => ApiError::unauthorized(),
136 GitError::Forbidden => ApiError::forbidden(),
137 other => ApiError::internal(other.to_string()),
138 }
139}
140
141fn map_issue(error: issues::errors::IssueError) -> ApiError {
142 use issues::errors::IssueError;
143 match error {
144 IssueError::BadRequest(message) | IssueError::Conflict(message) => {
145 ApiError::bad_request(message)
146 }
147 IssueError::Unauthorized => ApiError::unauthorized(),
148 IssueError::Forbidden => ApiError::forbidden(),
149 IssueError::NotFound => ApiError::not_found("resource not found"),
150 other => ApiError::internal(other.to_string()),
151 }
152}
153
154fn map_agent(error: agents::errors::AgentError) -> ApiError {
155 use agents::errors::AgentError;
156 match error {
157 AgentError::BadRequest(message) | AgentError::Conflict(message) => {
158 ApiError::bad_request(message)
159 }
160 AgentError::Unauthorized => ApiError::unauthorized(),
161 AgentError::Forbidden => ApiError::forbidden(),
162 AgentError::NotFound => ApiError::not_found("resource not found"),
163 other => ApiError::internal(other.to_string()),
164 }
165}
166
167fn db_internal(error: sea_orm::DbErr) -> ApiError {
168 ApiError::internal(error.to_string())
169}
170
171async fn require_owner(
172 state: &CleanupState,
173 space_name: &str,
174 uid: Uuid,
175) -> Result<models::space::spaces::Model, ApiError> {
176 let space = lookup_space_by_name(&state.space, space_name)
177 .await
178 .map_err(map_space)?;
179 if space.owner_id != uid {
180 return Err(ApiError::forbidden());
181 }
182 Ok(space)
183}
184
185fn room_kind_name(kind: rooms::RoomKind) -> &'static str {
186 use rooms::RoomKind;
187 match kind {
188 RoomKind::GuildRoom => "guild_room",
189 RoomKind::Text => "text",
190 RoomKind::Voice => "voice",
191 RoomKind::Stage => "stage",
192 RoomKind::Category => "category",
193 RoomKind::News => "news",
194 RoomKind::Thread => "thread",
195 RoomKind::GroupDm => "group_dm",
196 RoomKind::Dm => "dm",
197 RoomKind::DirectMessage => "direct_message",
198 }
199}
200
201/// 目录路径:沿 `parent_dir_id` 收集名字再反转为 `a / b / c`。
202fn dir_path(node: &dirs::Model, index: &HashMap<Uuid, (String, Option<Uuid>)>) -> String {
203 let mut names = vec![node.name.clone()];
204 let mut cursor = node.parent_dir_id;
205 let mut guard = 0;
206 while let Some(id) = cursor {
207 let Some((name, parent)) = index.get(&id) else {
208 break;
209 };
210 names.push(name.clone());
211 cursor = *parent;
212 guard += 1;
213 if guard > 64 {
214 break;
215 }
216 }
217 names.reverse();
218 names.join(" / ")
219}
220
221fn room_path(room: &rooms::Model, index: &HashMap<Uuid, (String, Option<Uuid>)>) -> String {
222 let mut names = vec![room.name.clone()];
223 let mut cursor = room.parent_id;
224 let mut guard = 0;
225 while let Some(id) = cursor {
226 let Some((name, parent)) = index.get(&id) else {
227 break;
228 };
229 names.push(name.clone());
230 cursor = *parent;
231 guard += 1;
232 if guard > 64 {
233 break;
234 }
235 }
236 names.reverse();
237 names.join(" / ")
238}
239
240async fn repo_row_by_dir(
241 db: &sea_orm::DatabaseConnection,
242 dir_id: Uuid,
243) -> Result<repos::Model, ApiError> {
244 repos::Entity::find()
245 .filter(repos::Column::DirId.eq(dir_id))
246 .one(db)
247 .await
248 .map_err(db_internal)?
249 .ok_or_else(|| ApiError::not_found("repository not found"))
250}
251
252#[utoipa::path(
253 get,
254 path = "/spaces/{space_name}/cleanup",
255 tag = "cleanup",
256 description = "列出空间内全部目录与对象(目录/仓库/问题集/智能体集/频道),仅空间所有者可访问。",
257 params(("space_name" = String, Path, description = "space name")),
258 responses(
259 (status = 200, description = "cleanup objects", body = AppResponseData<Vec<CleanupObjectDto>>)
260 )
261)]
262pub async fn list_cleanup_objects(
263 State(state): State<CleanupState>,
264 session: Session,
265 Path(space_name): Path<String>,
266) -> Result<AppResponseData<Vec<CleanupObjectDto>>, ApiError> {
267 let uid = require_uid(&session)?;
268 let space = require_owner(&state, &space_name, uid).await?;
269 let db = state.space.db();
270
271 let nodes = dirs::Entity::find()
272 .filter(dirs::Column::SpaceId.eq(space.id))
273 .all(db.read())
274 .await
275 .map_err(db_internal)?;
276 let node_ids: Vec<Uuid> = nodes.iter().map(|node| node.id).collect();
277
278 let (repo_rows, board_rows, collection_rows) = if node_ids.is_empty() {
279 (Vec::new(), Vec::new(), Vec::new())
280 } else {
281 (
282 repos::Entity::find()
283 .filter(repos::Column::DirId.is_in(node_ids.clone()))
284 .all(db.read())
285 .await
286 .map_err(db_internal)?,
287 boards::Entity::find()
288 .filter(boards::Column::DirId.is_in(node_ids.clone()))
289 .all(db.read())
290 .await
291 .map_err(db_internal)?,
292 agent_collections::Entity::find()
293 .filter(agent_collections::Column::DirId.is_in(node_ids.clone()))
294 .all(db.read())
295 .await
296 .map_err(db_internal)?,
297 )
298 };
299
300 let (group_ows, user_ows) = if node_ids.is_empty() {
301 (Vec::new(), Vec::new())
302 } else {
303 (
304 dir_overwrite_group::Entity::find()
305 .filter(dir_overwrite_group::Column::DirId.is_in(node_ids.clone()))
306 .all(db.read())
307 .await
308 .map_err(db_internal)?,
309 dir_overwrite_users::Entity::find()
310 .filter(dir_overwrite_users::Column::DirId.is_in(node_ids.clone()))
311 .all(db.read())
312 .await
313 .map_err(db_internal)?,
314 )
315 };
316
317 let room_rows = rooms::Entity::find()
318 .filter(rooms::Column::SpaceId.eq(space.id))
319 .filter(rooms::Column::IsActive.eq(true))
320 .all(db.read())
321 .await
322 .map_err(db_internal)?;
323
324 let repo_by_dir: HashMap<Uuid, &repos::Model> =
325 repo_rows.iter().map(|row| (row.dir_id, row)).collect();
326 let board_by_dir: HashMap<Uuid, &boards::Model> =
327 board_rows.iter().map(|row| (row.dir_id, row)).collect();
328 let collection_by_dir: HashMap<Uuid, &agent_collections::Model> = collection_rows
329 .iter()
330 .map(|row| (row.dir_id, row))
331 .collect();
332
333 let mut overwrite_counts: HashMap<Uuid, i64> = HashMap::new();
334 for row in group_ows.iter() {
335 *overwrite_counts.entry(row.dir_id).or_default() += 1;
336 }
337 for row in user_ows.iter() {
338 *overwrite_counts.entry(row.dir_id).or_default() += 1;
339 }
340
341 let mut dir_child_counts: HashMap<Uuid, i64> = HashMap::new();
342 for node in &nodes {
343 if let Some(parent) = node.parent_dir_id {
344 *dir_child_counts.entry(parent).or_default() += 1;
345 }
346 }
347 let mut room_child_counts: HashMap<Uuid, i64> = HashMap::new();
348 for room in &room_rows {
349 if let Some(parent) = room.parent_id {
350 *room_child_counts.entry(parent).or_default() += 1;
351 }
352 }
353
354 let dir_index: HashMap<Uuid, (String, Option<Uuid>)> = nodes
355 .iter()
356 .map(|node| (node.id, (node.name.clone(), node.parent_dir_id)))
357 .collect();
358 let room_index: HashMap<Uuid, (String, Option<Uuid>)> = room_rows
359 .iter()
360 .map(|room| (room.id, (room.name.clone(), room.parent_id)))
361 .collect();
362
363 let mut objects = Vec::with_capacity(nodes.len() + room_rows.len());
364 for node in &nodes {
365 let (kind, name, is_private, is_archived) = if let Some(repo) = repo_by_dir.get(&node.id) {
366 (
367 "repo",
368 repo.name.clone(),
369 Some(repo.is_private),
370 repo.is_archived,
371 )
372 } else if let Some(board) = board_by_dir.get(&node.id) {
373 ("board", board.name.clone(), None, false)
374 } else if let Some(collection) = collection_by_dir.get(&node.id) {
375 ("collection", collection.name.clone(), None, false)
376 } else if node.kind == dirs::DirKind::Object {
377 ("object", node.name.clone(), None, node.is_archived)
378 } else {
379 ("dir", node.name.clone(), None, node.is_archived)
380 };
381 objects.push(CleanupObjectDto {
382 id: node.id,
383 kind: kind.to_string(),
384 name,
385 path: dir_path(node, &dir_index),
386 is_private,
387 is_archived,
388 child_count: dir_child_counts.get(&node.id).copied().unwrap_or(0),
389 overwrite_count: overwrite_counts.get(&node.id).copied().unwrap_or(0),
390 });
391 }
392 for room in &room_rows {
393 objects.push(CleanupObjectDto {
394 id: room.id,
395 kind: room_kind_name(room.kind).to_string(),
396 name: room.name.clone(),
397 path: room_path(room, &room_index),
398 is_private: Some(room.is_private),
399 is_archived: false,
400 child_count: room_child_counts.get(&room.id).copied().unwrap_or(0),
401 overwrite_count: 0,
402 });
403 }
404
405 objects.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.kind.cmp(&b.kind)));
406 Ok(AppResponseData::ok(objects))
407}
408
409#[utoipa::path(
410 post,
411 path = "/spaces/{space_name}/cleanup/{kind}/{object_id}/visibility",
412 tag = "cleanup",
413 description = "调整对象可见性:仓库与频道支持私有/公开,其余类型不支持。",
414 params(
415 ("space_name" = String, Path, description = "space name"),
416 ("kind" = String, Path, description = "object kind"),
417 ("object_id" = Uuid, Path, description = "dir id or room id")
418 ),
419 request_body = SetCleanupVisibilityCmd,
420 responses((status = 200, description = "updated", body = AppResponseData<NoData>))
421)]
422pub async fn set_cleanup_visibility(
423 State(state): State<CleanupState>,
424 session: Session,
425 Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
426 Json(cmd): Json<SetCleanupVisibilityCmd>,
427) -> Result<AppResponseData<NoData>, ApiError> {
428 let uid = require_uid(&session)?;
429 let space = require_owner(&state, &space_name, uid).await?;
430 let db = state.space.db();
431
432 match kind.as_str() {
433 "repo" => {
434 let repo = repo_row_by_dir(db.read(), object_id).await?;
435 let update = git::service::repos::UpdateRepoCmd {
436 is_private: Some(cmd.is_private),
437 ..Default::default()
438 };
439 state
440 .git
441 .update_repo(space_name.clone(), repo.name, uid, update)
442 .await
443 .map_err(map_git)?;
444 }
445 "text" | "voice" | "stage" | "category" | "news" | "guild_room" | "thread" => {
446 let room = rooms::Entity::find_by_id(object_id)
447 .filter(rooms::Column::SpaceId.eq(space.id))
448 .one(db.read())
449 .await
450 .map_err(db_internal)?
451 .ok_or_else(|| ApiError::not_found("room not found"))?;
452 let mut am: rooms::ActiveModel = room.into();
453 am.is_private = Set(cmd.is_private);
454 am.updated_at = Set(time::OffsetDateTime::now_utc());
455 am.update(db.write()).await.map_err(db_internal)?;
456 }
457 _ => {
458 return Err(ApiError::bad_request(
459 "visibility is not adjustable for this object type".to_string(),
460 ));
461 }
462 }
463 Ok(AppResponseData::empty())
464}
465
466#[utoipa::path(
467 post,
468 path = "/spaces/{space_name}/cleanup/{kind}/{object_id}/archive",
469 tag = "cleanup",
470 description = "归档 / 取消归档仓库;仓库没有硬删除,危险区用归档表达下架。",
471 params(
472 ("space_name" = String, Path, description = "space name"),
473 ("kind" = String, Path, description = "object kind"),
474 ("object_id" = Uuid, Path, description = "dir id")
475 ),
476 request_body = SetRepoArchivedCmd,
477 responses((status = 200, description = "updated", body = AppResponseData<NoData>))
478)]
479pub async fn set_cleanup_archive(
480 State(state): State<CleanupState>,
481 session: Session,
482 Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
483 Json(cmd): Json<SetRepoArchivedCmd>,
484) -> Result<AppResponseData<NoData>, ApiError> {
485 let uid = require_uid(&session)?;
486 require_owner(&state, &space_name, uid).await?;
487 if kind != "repo" {
488 return Err(ApiError::bad_request(
489 "only repositories support archiving".to_string(),
490 ));
491 }
492 let repo = repo_row_by_dir(state.space.db().read(), object_id).await?;
493 state
494 .git
495 .set_repo_archived(space_name.clone(), repo.name, uid, cmd.archived)
496 .await
497 .map_err(map_git)?;
498 Ok(AppResponseData::empty())
499}
500
501#[utoipa::path(
502 delete,
503 path = "/spaces/{space_name}/cleanup/{kind}/{object_id}",
504 tag = "cleanup",
505 description = "删除对象:问题集/智能体集/频道调用各自模块的清理逻辑,纯目录节点需为空;仓库只能用归档。",
506 params(
507 ("space_name" = String, Path, description = "space name"),
508 ("kind" = String, Path, description = "object kind"),
509 ("object_id" = Uuid, Path, description = "dir id or room id")
510 ),
511 responses((status = 200, description = "deleted", body = AppResponseData<NoData>))
512)]
513pub async fn delete_cleanup_object(
514 State(state): State<CleanupState>,
515 session: Session,
516 Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
517) -> Result<AppResponseData<NoData>, ApiError> {
518 let uid = require_uid(&session)?;
519 let space = require_owner(&state, &space_name, uid).await?;
520 let db = state.space.db();
521
522 match kind.as_str() {
523 "repo" => {
524 return Err(ApiError::bad_request(
525 "repositories cannot be deleted here; archive them instead".to_string(),
526 ));
527 }
528 "board" => {
529 let board = boards::Entity::find()
530 .filter(boards::Column::DirId.eq(object_id))
531 .one(db.read())
532 .await
533 .map_err(db_internal)?
534 .ok_or_else(|| ApiError::not_found("board not found"))?;
535 state
536 .issues
537 .delete_board(uid, &space_name, board.id)
538 .await
539 .map_err(map_issue)?;
540 }
541 "collection" => {
542 let collection = agent_collections::Entity::find()
543 .filter(agent_collections::Column::DirId.eq(object_id))
544 .one(db.read())
545 .await
546 .map_err(db_internal)?
547 .ok_or_else(|| ApiError::not_found("collection not found"))?;
548 state
549 .agents
550 .delete_agent_collection(uid, &space_name, collection.id)
551 .await
552 .map_err(map_agent)?;
553 }
554 "text" | "voice" | "stage" | "category" | "news" | "guild_room" | "thread" => {
555 let room = rooms::Entity::find_by_id(object_id)
556 .filter(rooms::Column::SpaceId.eq(space.id))
557 .one(db.read())
558 .await
559 .map_err(db_internal)?
560 .ok_or_else(|| ApiError::not_found("room not found"))?;
561 let children = rooms::Entity::find()
562 .filter(rooms::Column::ParentId.eq(Some(room.id)))
563 .filter(rooms::Column::IsActive.eq(true))
564 .count(db.read())
565 .await
566 .map_err(db_internal)?;
567 if children > 0 {
568 return Err(ApiError::bad_request(format!(
569 "channel has {children} sub-channel(s); delete or move them first"
570 )));
571 }
572 state
573 .space
574 .delete_room(uid, room.id)
575 .await
576 .map_err(map_space)?;
577 }
578 "dir" | "object" => {
579 let node = dirs::Entity::find_by_id(object_id)
580 .filter(dirs::Column::SpaceId.eq(space.id))
581 .one(db.read())
582 .await
583 .map_err(db_internal)?
584 .ok_or_else(|| ApiError::not_found("directory not found"))?;
585 let has_companion = repos::Entity::find()
586 .filter(repos::Column::DirId.eq(node.id))
587 .count(db.read())
588 .await
589 .map_err(db_internal)?
590 > 0
591 || boards::Entity::find()
592 .filter(boards::Column::DirId.eq(node.id))
593 .count(db.read())
594 .await
595 .map_err(db_internal)?
596 > 0
597 || agent_collections::Entity::find()
598 .filter(agent_collections::Column::DirId.eq(node.id))
599 .count(db.read())
600 .await
601 .map_err(db_internal)?
602 > 0;
603 if has_companion {
604 return Err(ApiError::bad_request(
605 "this node holds an object; delete it from its own row instead".to_string(),
606 ));
607 }
608 let children = dirs::Entity::find()
609 .filter(dirs::Column::ParentDirId.eq(Some(node.id)))
610 .count(db.read())
611 .await
612 .map_err(db_internal)?;
613 if children > 0 {
614 return Err(ApiError::bad_request(format!(
615 "directory has {children} child node(s); empty it first"
616 )));
617 }
618 let txn = db.write().begin().await.map_err(db_internal)?;
619 dir_overwrite_group::Entity::delete_many()
620 .filter(dir_overwrite_group::Column::DirId.eq(node.id))
621 .exec(&txn)
622 .await
623 .map_err(db_internal)?;
624 dir_overwrite_users::Entity::delete_many()
625 .filter(dir_overwrite_users::Column::DirId.eq(node.id))
626 .exec(&txn)
627 .await
628 .map_err(db_internal)?;
629 dirs::Entity::delete_by_id(node.id)
630 .exec(&txn)
631 .await
632 .map_err(db_internal)?;
633 txn.commit().await.map_err(db_internal)?;
634 }
635 _ => {
636 return Err(ApiError::bad_request("unsupported object kind".to_string()));
637 }
638 }
639 Ok(AppResponseData::empty())
640}
641
642#[utoipa::path(
643 delete,
644 path = "/spaces/{space_name}/cleanup/{kind}/{object_id}/overwrites",
645 tag = "cleanup",
646 description = "清除目录节点上的全部可见性覆盖(group + user),恢复继承默认可见性。",
647 params(
648 ("space_name" = String, Path, description = "space name"),
649 ("kind" = String, Path, description = "object kind"),
650 ("object_id" = Uuid, Path, description = "dir id")
651 ),
652 responses((status = 200, description = "cleared", body = AppResponseData<NoData>))
653)]
654pub async fn clear_cleanup_overwrites(
655 State(state): State<CleanupState>,
656 session: Session,
657 Path((space_name, kind, object_id)): Path<(String, String, Uuid)>,
658) -> Result<AppResponseData<NoData>, ApiError> {
659 let uid = require_uid(&session)?;
660 let space = require_owner(&state, &space_name, uid).await?;
661 if !matches!(
662 kind.as_str(),
663 "dir" | "object" | "repo" | "board" | "collection"
664 ) {
665 return Err(ApiError::bad_request(
666 "visibility overwrites are stored on directory nodes only".to_string(),
667 ));
668 }
669 let db = state.space.db();
670 dirs::Entity::find_by_id(object_id)
671 .filter(dirs::Column::SpaceId.eq(space.id))
672 .one(db.read())
673 .await
674 .map_err(db_internal)?
675 .ok_or_else(|| ApiError::not_found("directory not found"))?;
676 let txn = db.write().begin().await.map_err(db_internal)?;
677 dir_overwrite_group::Entity::delete_many()
678 .filter(dir_overwrite_group::Column::DirId.eq(object_id))
679 .exec(&txn)
680 .await
681 .map_err(db_internal)?;
682 dir_overwrite_users::Entity::delete_many()
683 .filter(dir_overwrite_users::Column::DirId.eq(object_id))
684 .exec(&txn)
685 .await
686 .map_err(db_internal)?;
687 txn.commit().await.map_err(db_internal)?;
688 Ok(AppResponseData::empty())
689}
690
691#[derive(OpenApi)]
692#[openapi(
693 paths(
694 crate::cleanup::list_cleanup_objects,
695 crate::cleanup::set_cleanup_visibility,
696 crate::cleanup::set_cleanup_archive,
697 crate::cleanup::delete_cleanup_object,
698 crate::cleanup::clear_cleanup_overwrites,
699 ),
700 components(schemas(
701 crate::cleanup::CleanupObjectDto,
702 crate::cleanup::SetCleanupVisibilityCmd,
703 crate::cleanup::SetRepoArchivedCmd,
704 AppResponseData<Vec<crate::cleanup::CleanupObjectDto>>,
705 AppResponseData<NoData>,
706 ))
707)]
708pub struct CleanupApiDoc;
Modified lib/webapi/src/lib.rs lib/webapi/src/lib.rs +10 −1 View lib/webapi/src/lib.rs
@@ -1,3 +1,4 @@
1pub mod cleanup;
12pub mod metrics;
23pub mod openapi;
34pub mod search;
@@ -49,6 +50,7 @@
4950 let cors = build_cors_layer(&state.config.web);
5051
5152 let auth = auth::services::AuthService::new(state.clone());
53 let agents = agents::services::service::AgentsService::new(state.clone());
5254 let chat = chat::services::service::ChatService::new(state.clone());
5355 let issues = issues::services::service::IssueService::new(state.clone());
5456 let space = space::services::service::SpaceService::new(state.clone());
@@ -62,8 +64,14 @@
6264 let import = ::import::ImportService::new(state.clone());
6365 let health_state = state;
6466
67 // 清理接口需要跨模块调用(space/issues/agents/git),这里的服务都还没被
68 // 其他 router 的 with_state 消费掉,先构造再合并。
69 let cleanup_router =
70 cleanup::router(space.clone(), issues.clone(), agents.clone(), git.clone());
71
6572 let public: Router = Router::new()
6673 .merge(auth::routes::router(auth.clone()).with_state(auth.clone()))
74 .merge(agents::routes::router(agents.clone()).with_state(agents))
6775 .merge(chat::routes::router(chat.clone(), space.clone()).with_state(chat))
6876 .merge(issues::routes::router(issues.clone()).with_state(issues))
6977 .merge(space::routes::router(space.clone()).with_state(space))
@@ -72,7 +80,8 @@
7280 .merge(pulls::routes::router().with_state(pulls))
7381 .merge(crate::search::router().with_state(search.clone()))
7482 .merge(::import::routes::router(import.clone()).with_state(import.clone()))
75 .merge(ci::routes::router().with_state(ci.clone()));
83 .merge(ci::routes::router().with_state(ci.clone()))
84 .merge(cleanup_router);
7685 let internal: Router = Router::new()
7786 .merge(git::routes::internal_router(git.clone()).with_state(git))
7887 .merge(crate::search::internal_router().with_state(search))
Modified lib/webapi/src/openapi.rs lib/webapi/src/openapi.rs +2 −0 View lib/webapi/src/openapi.rs
@@ -105,6 +105,7 @@
105105 }};
106106 }
107107 m!("auth", auth::openapi::AuthApiDoc::openapi());
108 m!("agents", agents::openapi::AgentsApiDoc::openapi());
108109 m!("chat", chat::openapi::ChatApiDoc::openapi());
109110 m!("issues", issues::openapi::IssuesApiDoc::openapi());
110111 m!("space", space::openapi::SpaceApiDoc::openapi());
@@ -114,6 +115,7 @@
114115 m!("search", crate::search::SearchApiDoc::openapi());
115116 m!("import", import::openapi::ImportApiDoc::openapi());
116117 m!("ci", ci::openapi::PipelinesApiDoc::openapi());
118 m!("cleanup", crate::cleanup::CleanupApiDoc::openapi());
117119 doc.servers = Some(vec![utoipa::openapi::Server::new("/api/v1")]);
118120 doc.info.description = Some(CONVENTIONS.to_string());
119121 doc.info.license = None;
Modified lib/webapi/src/search.rs lib/webapi/src/search.rs +7 −0 View lib/webapi/src/search.rs
@@ -21,6 +21,8 @@
2121#[derive(Debug, Clone, Deserialize, ToSchema)]
2222pub struct ReindexCommand {
2323 pub repo_id: Option<Uuid>,
24 #[serde(default)]
25 pub force_full: bool,
2426}
2527
2628#[derive(Debug)]
@@ -149,6 +151,11 @@
149151 State(service): State<SearchService>,
150152 Json(command): Json<ReindexCommand>,
151153) -> Result<AppResponseData<NoData>, SearchApiError> {
154 if command.force_full {
155 ::search::outbox::clear_repo_state(service.database().write(), command.repo_id)
156 .await
157 .map_err(SearchError::from)?;
158 }
152159 if let Some(repo_id) = command.repo_id {
153160 ::search::outbox::enqueue_repo(service.database().write(), repo_id)
154161 .await
Modified lib/webapi/Cargo.toml lib/webapi/Cargo.toml +5 −0 View lib/webapi/Cargo.toml
@@ -6,6 +6,7 @@
66[dependencies]
77config = { path = "../config" }
88auth = { workspace = true }
9agents = { workspace = true }
910chat = { workspace = true }
1011issues = { workspace = true }
1112pulls = { workspace = true }
@@ -15,6 +16,8 @@
1516ci = { workspace = true }
1617import = { workspace = true }
1718search = { workspace = true }
19models = { workspace = true }
20sea-orm = { workspace = true }
1821export = { workspace = true }
1922session-layer = { workspace = true }
2023axum = { workspace = true }
@@ -28,5 +31,7 @@
2831opentelemetry-otlp = { workspace = true }
2932web = { workspace = true }
3033serde = { workspace = true, features = ["derive"] }
34serde_json = { workspace = true }
3135uuid = { workspace = true }
36time = { workspace = true }
3237
Modified web/src/app/routes/deferred-routes.tsx web/src/app/routes/deferred-routes.tsx +5 −0 View web/src/app/routes/deferred-routes.tsx
@@ -11,6 +11,7 @@
1111const NotFoundPage = lazy(() => import('@/pages/not-found').then(page => ({ default: page.NotFoundPage })))
1212const ResetPasswordPage = lazy(() => import('@/pages/reset-password').then(page => ({ default: page.ResetPasswordPage })))
1313const SignUpPage = lazy(() => import('@/pages/sign-up').then(page => ({ default: page.SignUpPage })))
14const SpaceInvitePage = lazy(() => import('@/pages/space-invite').then(page => ({ default: page.SpaceInvitePage })))
1415const TwoFactorPage = lazy(() => import('@/pages/two-factor').then(page => ({ default: page.TwoFactorPage })))
1516
1617function RoutePending() {
@@ -55,6 +56,10 @@
5556 return <DeferredRoute><GuestOnly><TwoFactorPage /></GuestOnly></DeferredRoute>
5657}
5758
59export function SpaceInviteRoute() {
60 return <DeferredRoute><SpaceInvitePage /></DeferredRoute>
61}
62
5863export function AgreementRoute() {
5964 return <DeferredRoute><AgreementPage /></DeferredRoute>
6065}
Modified web/src/app/routes/router.tsx web/src/app/routes/router.tsx +3 −0 View web/src/app/routes/router.tsx
@@ -8,6 +8,7 @@
88 NotFoundRoute,
99 ResetPasswordRoute,
1010 SignUpRoute,
11 SpaceInviteRoute,
1112 TwoFactorRoute,
1213 WorkspaceRoute,
1314} from './deferred-routes'
@@ -42,6 +43,7 @@
4243 { path: ':spaceName' },
4344 { path: ':spaceName/chat/:roomId?' },
4445 { path: ':spaceName/board/:boardId?' },
46 { path: ':spaceName/agent/:collectionId?' },
4547 { path: ':spaceName/repo/:repoName?' },
4648 { path: ':spaceName/members' },
4749 ],
@@ -53,6 +55,7 @@
5355 { path: '/auth/reset-password', element: <ResetPasswordRoute /> },
5456 { path: '/auth/verify-email', element: <EmailVerificationRoute /> },
5557 { path: '/auth/2fa', element: <TwoFactorRoute /> },
58 { path: '/spaces/invites/accept', element: <SpaceInviteRoute /> },
5659 { path: '/agreements/:key', element: <AgreementRoute /> },
5760 { path: '*', element: <NotFoundRoute /> },
5861])
Added web/src/pages/workspace/model/agent-api.ts web/src/pages/workspace/model/agent-api.ts +243 −0 View web/src/pages/workspace/model/agent-api.ts
@@ -0,0 +1,243 @@
1/**
2 * Agent-set / agent API wrappers. All agent execution goes through the main
3 * API, which bridges to the standalone sandbox-agent runtime over gRPC; the
4 * web client never talks to that runtime directly.
5 */
6import Apis from '@/shared/api'
7import { ApiFailure, unwrapApi, type ApiEnvelope } from '@/shared/lib'
8import type {
9 AnswerAgentQuestionCmd,
10 AppResponseData_BuilderChatResult,
11 AppResponseData_AgentCollectionDto,
12 AppResponseData_AgentDto,
13 AppResponseData_AgentRunDto,
14 AppResponseData_AgentStreamTokenDto,
15 AppResponseData_Vec_AgentApprovalDto,
16 AppResponseData_Vec_AgentMessageDto,
17 AppResponseData_Vec_AgentQuestionDto,
18 AppResponseData_Vec_AgentToolCallDto,
19 CreateAgentCmd,
20 DecideAgentApprovalCmd,
21} from '@/shared/api'
22
23/**
24 * Payload aliases read straight out of the generated envelopes, matching the
25 * entities' own convention; the generator only exports envelope interfaces.
26 */
27export type AgentCollectionDto = NonNullable<AppResponseData_AgentCollectionDto['data']>
28export type AgentDto = NonNullable<AppResponseData_AgentDto['data']>
29export type AgentRunDto = NonNullable<AppResponseData_AgentRunDto['data']>
30export type AgentMessageDto = NonNullable<AppResponseData_Vec_AgentMessageDto['data']>[number]
31export type AgentApprovalDto = NonNullable<AppResponseData_Vec_AgentApprovalDto['data']>[number]
32export type AgentQuestionDto = NonNullable<AppResponseData_Vec_AgentQuestionDto['data']>[number]
33export type AgentStreamToken = NonNullable<AppResponseData_AgentStreamTokenDto['data']>
34export type AgentToolCallDto = NonNullable<AppResponseData_Vec_AgentToolCallDto['data']>[number]
35export type BuilderChatResultDto = NonNullable<AppResponseData_BuilderChatResult['data']>
36export type BuilderDraftDto = BuilderChatResultDto['draft']
37export type BuilderMessageDto = { role: 'user' | 'assistant'; content: string }
38
39export const fetchAgentCollection = (spaceName: string, collectionId: string) =>
40 unwrapApi<AgentCollectionDto>(
41 Apis.agents.get_agent_collection({
42 pathParams: { space_name: spaceName, collection_id: collectionId },
43 }),
44 )
45
46// `list_agents` is typed against the generic `Page` schema; items are `AgentDto`.
47export const fetchAgents = (spaceName: string, collectionId: string) =>
48 unwrapApi<{ items: AgentDto[] }>(
49 Apis.agents.list_agents({
50 pathParams: { space_name: spaceName, collection_id: collectionId },
51 params: { limit: 100 },
52 }) as unknown as PromiseLike<ApiEnvelope<{ items: AgentDto[] }>>,
53 ).then(p => p?.items ?? [])
54
55export const createAgent = (spaceName: string, collectionId: string, cmd: CreateAgentCmd) =>
56 unwrapApi<AgentDto>(
57 Apis.agents.create_agent({
58 pathParams: { space_name: spaceName, collection_id: collectionId },
59 data: cmd,
60 }),
61 )
62
63export const deleteAgent = (spaceName: string, collectionId: string, agentId: string) =>
64 unwrapApi<unknown>(
65 Apis.agents.delete_agent({
66 pathParams: { space_name: spaceName, collection_id: collectionId, agent_id: agentId },
67 }),
68 )
69
70/**
71 * One builder turn: the whole client-owned conversation goes up, and the reply
72 * plus the updated draft come back.
73 */
74export const builderChat = (
75 spaceName: string,
76 collectionId: string,
77 messages: BuilderMessageDto[],
78) =>
79 unwrapApi<BuilderChatResultDto>(
80 Apis.agents.builder_chat({
81 pathParams: { space_name: spaceName, collection_id: collectionId },
82 data: { messages },
83 }),
84 )
85
86/** Raw-image upload for the agent avatar; the runtime resolves it to a URL. */
87export const uploadAgentAvatar = async (
88 spaceName: string,
89 collectionId: string,
90 agentId: string,
91 file: File,
92): Promise<AgentDto> => {
93 const response = await fetch(
94 `/api/v1/agents/${encodeURIComponent(spaceName)}/collections/${collectionId}/agents/${agentId}/avatar`,
95 {
96 method: 'PUT',
97 credentials: 'include',
98 headers: { 'Content-Type': file.type || 'application/octet-stream' },
99 body: file,
100 },
101 )
102 const body = await response.json().catch(() => null)
103 if (!response.ok) {
104 throw new ApiFailure(body?.message ?? `HTTP ${response.status}`, response.status)
105 }
106 return body?.data as AgentDto
107}
108
109/**
110 * Mint the short-lived grant the browser uses to connect to the runtime's
111 * fanout SSE directly (`?token=`); the main API is not in the event path.
112 */
113export const createAgentStreamToken = (spaceName: string, collectionId: string, agentId: string) =>
114 unwrapApi<AgentStreamToken>(
115 Apis.agents.create_agent_stream_token({
116 pathParams: { space_name: spaceName, collection_id: collectionId, agent_id: agentId },
117 }),
118 )
119
120export const sendAgentMessage = (
121 spaceName: string,
122 collectionId: string,
123 agentId: string,
124 text: string,
125 mcp = false,
126 repo: string | null = null,
127) =>
128 unwrapApi<AgentRunDto>(
129 Apis.agents.send_agent_message({
130 pathParams: { space_name: spaceName, collection_id: collectionId, agent_id: agentId },
131 data: { text, mcp, repo },
132 }),
133 )
134
135export const fetchAgentMessages = (
136 spaceName: string,
137 collectionId: string,
138 agentId: string,
139 afterSequence?: number,
140) =>
141 unwrapApi<AgentMessageDto[]>(
142 Apis.agents.list_agent_messages({
143 pathParams: { space_name: spaceName, collection_id: collectionId, agent_id: agentId },
144 params: {
145 limit: 500,
146 ...(afterSequence === undefined ? {} : { after_sequence: afterSequence }),
147 },
148 }),
149 )
150
151/**
152 * Tool calls recorded by the runtime: the command/path each call used plus its
153 * status and duration. The transcript joins them onto tool messages through
154 * `external_call_id` so a result can render as "ran command X" like Codex.
155 */
156export const fetchAgentToolCalls = (
157 spaceName: string,
158 collectionId: string,
159 agentId: string,
160 limit = 1000,
161) =>
162 unwrapApi<AgentToolCallDto[]>(
163 Apis.agents.list_agent_tool_calls({
164 pathParams: { space_name: spaceName, collection_id: collectionId, agent_id: agentId },
165 params: { limit },
166 }),
167 )
168
169export const fetchAgentRuns = (spaceName: string, collectionId: string, agentId: string) =>
170 unwrapApi<AgentRunDto[]>(
171 Apis.agents.list_agent_runs({
172 pathParams: { space_name: spaceName, collection_id: collectionId, agent_id: agentId },
173 }),
174 )
175
176export const cancelAgentRun = (
177 spaceName: string,
178 collectionId: string,
179 agentId: string,
180 runId: string,
181) =>
182 unwrapApi<AgentRunDto>(
183 Apis.agents.cancel_agent_run({
184 pathParams: {
185 space_name: spaceName,
186 collection_id: collectionId,
187 agent_id: agentId,
188 run_id: runId,
189 },
190 }),
191 )
192
193export const fetchAgentApprovals = (spaceName: string, collectionId: string, agentId: string) =>
194 unwrapApi<AgentApprovalDto[]>(
195 Apis.agents.list_agent_approvals({
196 pathParams: { space_name: spaceName, collection_id: collectionId, agent_id: agentId },
197 }),
198 )
199
200export const decideAgentApproval = (
201 spaceName: string,
202 collectionId: string,
203 agentId: string,
204 approvalId: string,
205 cmd: DecideAgentApprovalCmd,
206) =>
207 unwrapApi<AgentApprovalDto>(
208 Apis.agents.decide_agent_approval({
209 pathParams: {
210 space_name: spaceName,
211 collection_id: collectionId,
212 agent_id: agentId,
213 approval_id: approvalId,
214 },
215 data: cmd,
216 }),
217 )
218
219export const fetchAgentQuestions = (spaceName: string, collectionId: string, agentId: string) =>
220 unwrapApi<AgentQuestionDto[]>(
221 Apis.agents.list_agent_questions({
222 pathParams: { space_name: spaceName, collection_id: collectionId, agent_id: agentId },
223 }),
224 )
225
226export const answerAgentQuestions = (
227 spaceName: string,
228 collectionId: string,
229 agentId: string,
230 questionId: string,
231 cmd: AnswerAgentQuestionCmd,
232) =>
233 unwrapApi<AgentQuestionDto>(
234 Apis.agents.answer_agent_questions({
235 pathParams: {
236 space_name: spaceName,
237 collection_id: collectionId,
238 agent_id: agentId,
239 question_id: questionId,
240 },
241 data: cmd,
242 }),
243 )
No newline at end of file
Added web/src/pages/workspace/ui/agent/agent-builder-modal.tsx web/src/pages/workspace/ui/agent/agent-builder-modal.tsx +406 −0 View web/src/pages/workspace/ui/agent/agent-builder-modal.tsx
@@ -0,0 +1,406 @@
1import { useEffect, useRef, useState } from 'react'
2import { ArrowUp, ImagePlus, Loader2, Plus, Sparkles, X } from 'lucide-react'
3import { useT } from '@/shared/i18n'
4import { ApiFailure } from '@/shared/lib'
5import {
6 builderChat,
7 createAgent,
8 uploadAgentAvatar,
9 type AgentDto,
10 type BuilderMessageDto,
11} from '../../model/agent-api'
12
13export interface AgentBuilderModalProps {
14 open: boolean
15 spaceName: string
16 collectionId: string
17 runtimeOnline: boolean
18 onClose: () => void
19 onCreated: (agent: AgentDto) => void
20}
21
22const MAX_DESCRIPTION = 255
23const MAX_STARTERS = 3
24
25/**
26 * Large two-pane agent builder: the left column drafts the configuration in a
27 * conversation with the platform model, the right column edits the draft
28 * directly. Creating posts the merged draft and uploads the optional avatar.
29 */
30export function AgentBuilderModal(props: AgentBuilderModalProps) {
31 return props.open ? <AgentBuilderDialog {...props} /> : null
32}
33
34/** Mounted only while the drawer is open, so closing always resets the draft. */
35function AgentBuilderDialog({
36 spaceName,
37 collectionId,
38 runtimeOnline,
39 onClose,
40 onCreated,
41}: AgentBuilderModalProps) {
42 const { t } = useT()
43 const [messages, setMessages] = useState<BuilderMessageDto[]>([])
44 const [input, setInput] = useState('')
45 const [busy, setBusy] = useState(false)
46 const [creating, setCreating] = useState(false)
47 const [error, setError] = useState<string | null>(null)
48 const [name, setName] = useState('')
49 const [description, setDescription] = useState('')
50 const [instructions, setInstructions] = useState('')
51 const [starters, setStarters] = useState<string[]>([''])
52 const [avatarFile, setAvatarFile] = useState<File | null>(null)
53 const [avatarPreview, setAvatarPreview] = useState<string | null>(null)
54 const fileRef = useRef<HTMLInputElement>(null)
55 const inputRef = useRef<HTMLTextAreaElement>(null)
56 const scrollRef = useRef<HTMLDivElement>(null)
57 const chips = [
58 t('agent.builder.chip.review'),
59 t('agent.builder.chip.research'),
60 t('agent.builder.chip.plan'),
61 ]
62
63 useEffect(() => {
64 const timer = setTimeout(() => inputRef.current?.focus(), 60)
65 return () => clearTimeout(timer)
66 }, [])
67
68 useEffect(() => {
69 const onKey = (event: KeyboardEvent) => {
70 if (event.key === 'Escape' && !busy && !creating) onClose()
71 }
72 window.addEventListener('keydown', onKey)
73 return () => window.removeEventListener('keydown', onKey)
74 }, [busy, creating, onClose])
75
76 useEffect(() => {
77 const node = scrollRef.current
78 if (node) node.scrollTop = node.scrollHeight
79 }, [messages.length, busy])
80
81 const applyDraft = (draft: { name?: string | null; description?: string | null; instructions?: string | null; starters?: string[] }) => {
82 if (draft.name) setName(draft.name)
83 if (draft.description) setDescription(draft.description.slice(0, MAX_DESCRIPTION))
84 if (draft.instructions) setInstructions(draft.instructions)
85 const next = (draft.starters ?? []).filter(item => item.trim()).slice(0, MAX_STARTERS)
86 if (next.length > 0) {
87 setStarters([...next, ...Array(Math.max(0, MAX_STARTERS - next.length)).fill('')])
88 }
89 }
90
91 const send = async () => {
92 const text = input.trim()
93 if (!text || busy || creating) return
94 const next = [...messages, { role: 'user' as const, content: text }]
95 setMessages(next)
96 setInput('')
97 setBusy(true)
98 setError(null)
99 try {
100 const result = await builderChat(spaceName, collectionId, next)
101 setMessages(current => [...current, { role: 'assistant', content: result.reply }])
102 applyDraft(result.draft)
103 } catch (err) {
104 setError(err instanceof ApiFailure ? err.message : String(err))
105 } finally {
106 setBusy(false)
107 }
108 }
109
110 const pickAvatar = (file: File | null) => {
111 if (!file) return
112 setAvatarFile(file)
113 setAvatarPreview(URL.createObjectURL(file))
114 }
115
116 const setStarterAt = (index: number, value: string) => {
117 setStarters(current => current.map((item, position) => (position === index ? value : item)))
118 }
119
120 const create = async () => {
121 const trimmed = name.trim()
122 if (!trimmed) {
123 setError(t('agent.builder.nameRequired'))
124 return
125 }
126 setCreating(true)
127 setError(null)
128 try {
129 let agent = await createAgent(spaceName, collectionId, {
130 name: trimmed,
131 description: description.trim() || null,
132 instructions: instructions.trim() || null,
133 starters: starters.map(item => item.trim()).filter(Boolean).slice(0, MAX_STARTERS),
134 })
135 if (avatarFile) {
136 try {
137 agent = await uploadAgentAvatar(spaceName, collectionId, agent.id, avatarFile)
138 } catch {
139 // The agent exists; a failed avatar upload must not block creation.
140 }
141 }
142 onCreated(agent)
143 } catch (err) {
144 setError(err instanceof ApiFailure ? err.message : String(err))
145 } finally {
146 setCreating(false)
147 }
148 }
149
150 return (
151 <div
152 className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-6 py-8"
153 onMouseDown={event => {
154 if (event.target === event.currentTarget && !busy && !creating) onClose()
155 }}
156 >
157 <div
158 role="dialog"
159 aria-modal="true"
160 aria-label={t('agent.builder.title')}
161 className="flex h-[min(780px,92vh)] w-[min(1200px,96vw)] flex-col overflow-hidden rounded-2xl border border-shell-hairline bg-shell-surface shadow-2xl"
162 >
163 <header className="flex h-14 shrink-0 items-center justify-between border-b border-shell-hairline px-5">
164 <span className="flex items-center gap-2 text-[15px] font-semibold text-shell-ink">
165 <Sparkles className="h-4 w-4 text-shell-ink-muted" aria-hidden />
166 {t('agent.builder.title')}
167 </span>
168 <span className="flex items-center gap-1.5 text-[12px] text-shell-ink-muted">
169 <span
170 className={`h-2 w-2 rounded-full ${runtimeOnline ? 'bg-[#22c55e]' : 'bg-shell-tile'}`}
171 aria-hidden
172 />
173 {runtimeOnline ? t('agent.builder.online') : t('agent.builder.offline')}
174 </span>
175 </header>
176
177 <div className="flex min-h-0 flex-1">
178 <div className="flex min-w-0 flex-1 flex-col">
179 <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
180 {messages.length === 0 ? (
181 <div className="flex h-full flex-col items-center justify-center gap-2 text-center">
182 <p className="text-[19px] font-semibold text-shell-ink">{t('agent.builder.heroTitle')}</p>
183 <p className="text-[13px] text-shell-ink-muted">{t('agent.builder.heroHint')}</p>
184 <div className="mt-3 flex flex-wrap justify-center gap-2">
185 {chips.map(chip => (
186 <button
187 key={chip}
188 type="button"
189 onClick={() => {
190 setInput(chip)
191 inputRef.current?.focus()
192 }}
193 className="rounded-full border border-shell-hairline px-3 py-1.5 text-[12.5px] text-shell-ink-muted transition-colors hover:border-shell-tile hover:text-shell-ink"
194 >
195 {chip}
196 </button>
197 ))}
198 </div>
199 </div>
200 ) : (
201 <div className="mx-auto flex max-w-2xl flex-col gap-3">
202 {messages.map((message, index) => (
203 <div
204 key={index}
205 className={message.role === 'user' ? 'flex justify-end' : 'flex justify-start'}
206 >
207 <div
208 className={
209 message.role === 'user'
210 ? 'max-w-[85%] rounded-2xl bg-shell-ink px-3.5 py-2 text-[13px] leading-6 whitespace-pre-wrap text-shell-surface'
211 : 'max-w-[90%] rounded-2xl border border-shell-hairline bg-shell-surface px-3.5 py-2 text-[13px] leading-6 whitespace-pre-wrap text-shell-ink'
212 }
213 >
214 {message.content}
215 </div>
216 </div>
217 ))}
218 {busy ? (
219 <p className="flex items-center gap-2 text-[12.5px] text-shell-ink-muted">
220 <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
221 {t('agent.builder.thinking')}
222 </p>
223 ) : null}
224 </div>
225 )}
226 </div>
227 <div className="border-t border-shell-hairline p-4">
228 {error ? (
229 <p role="alert" className="mb-2 text-[12px] text-[#b42318]">
230 {error}
231 </p>
232 ) : null}
233 <div className="flex items-end gap-2 rounded-2xl border border-shell-hairline bg-shell-surface px-3 py-2">
234 <textarea
235 ref={inputRef}
236 value={input}
237 rows={1}
238 onChange={event => setInput(event.target.value)}
239 onKeyDown={event => {
240 if (event.key === 'Enter' && !event.shiftKey) {
241 event.preventDefault()
242 void send()
243 }
244 }}
245 placeholder={t('agent.builder.placeholder')}
246 className="max-h-32 min-h-[24px] w-full resize-none bg-transparent text-[13px] leading-6 text-shell-ink outline-none placeholder:text-shell-ink-muted"
247 />
248 <button
249 type="button"
250 onClick={() => void send()}
251 disabled={busy || !input.trim()}
252 title={t('agent.chat.send')}
253 aria-label={t('agent.chat.send')}
254 className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-shell-ink text-shell-surface transition-opacity hover:opacity-90 disabled:bg-shell-field disabled:text-shell-ink-muted"
255 >
256 <ArrowUp className="h-4 w-4" />
257 </button>
258 </div>
259 </div>
260 </div>
261
262 <aside className="flex w-[380px] shrink-0 flex-col border-l border-shell-hairline">
263 <div className="min-h-0 flex-1 overflow-y-auto p-5">
264 <h2 className="text-[15px] font-semibold text-shell-ink">{t('agent.builder.configTitle')}</h2>
265 <p className="mt-0.5 text-[12px] text-shell-ink-muted">{t('agent.builder.configHint')}</p>
266
267 <section className="mt-5">
268 <h3 className="text-[12.5px] font-semibold text-shell-ink">{t('agent.builder.identity')}</h3>
269 <div className="mt-2 rounded-xl border border-shell-hairline">
270 <div className="border-b border-shell-hairline p-3.5">
271 <p className="text-[12px] text-shell-ink-muted">{t('agent.builder.avatar')}</p>
272 <button
273 type="button"
274 onClick={() => fileRef.current?.click()}
275 className="mt-2 flex h-12 w-12 items-center justify-center overflow-hidden rounded-xl border border-shell-hairline text-shell-ink-muted transition-colors hover:text-shell-ink"
276 >
277 {avatarPreview ? (
278 <img src={avatarPreview} alt="" className="h-full w-full object-cover" />
279 ) : (
280 <ImagePlus className="h-5 w-5" aria-hidden />
281 )}
282 </button>
283 <input
284 ref={fileRef}
285 type="file"
286 accept="image/png,image/jpeg,image/webp,image/gif"
287 className="hidden"
288 onChange={event => pickAvatar(event.target.files?.[0] ?? null)}
289 />
290 </div>
291 <div className="border-b border-shell-hairline p-3.5">
292 <label className="text-[12px] text-shell-ink-muted" htmlFor="agent-builder-name">
293 {t('agent.builder.name')}
294 </label>
295 <input
296 id="agent-builder-name"
297 value={name}
298 onChange={event => setName(event.target.value)}
299 placeholder={t('agent.builder.namePlaceholder')}
300 className="mt-1.5 w-full rounded-lg border border-shell-hairline bg-transparent px-2.5 py-1.5 text-[13px] text-shell-ink outline-none placeholder:text-shell-ink-muted"
301 />
302 </div>
303 <div className="p-3.5">
304 <label className="text-[12px] text-shell-ink-muted" htmlFor="agent-builder-description">
305 {t('agent.builder.description')}
306 </label>
307 <textarea
308 id="agent-builder-description"
309 value={description}
310 rows={3}
311 maxLength={MAX_DESCRIPTION}
312 onChange={event => setDescription(event.target.value)}
313 placeholder={t('agent.builder.descriptionPlaceholder')}
314 className="mt-1.5 w-full resize-none rounded-lg border border-shell-hairline bg-transparent px-2.5 py-1.5 text-[13px] leading-5 text-shell-ink outline-none placeholder:text-shell-ink-muted"
315 />
316 <p className="mt-1 text-right text-[11px] text-shell-ink-muted">
317 {description.length} / {MAX_DESCRIPTION}
318 </p>
319 </div>
320 </div>
321 </section>
322
323 <section className="mt-6">
324 <h3 className="text-[12.5px] font-semibold text-shell-ink">{t('agent.builder.behavior')}</h3>
325 <div className="mt-2 rounded-xl border border-shell-hairline">
326 <div className="border-b border-shell-hairline p-3.5">
327 <label className="text-[12px] text-shell-ink-muted" htmlFor="agent-builder-instructions">
328 {t('agent.builder.instructions')}
329 </label>
330 <textarea
331 id="agent-builder-instructions"
332 value={instructions}
333 rows={7}
334 onChange={event => setInstructions(event.target.value)}
335 placeholder={t('agent.builder.instructionsPlaceholder')}
336 className="mt-1.5 w-full resize-y rounded-lg border border-shell-hairline bg-transparent px-2.5 py-1.5 text-[13px] leading-5 text-shell-ink outline-none placeholder:text-shell-ink-muted"
337 />
338 </div>
339 <div className="p-3.5">
340 <p className="text-[12px] text-shell-ink-muted">{t('agent.builder.starters')}</p>
341 <p className="mt-0.5 text-[11px] leading-4 text-shell-ink-muted">
342 {t('agent.builder.startersHint')}
343 </p>
344 <div className="mt-2 flex flex-col gap-2">
345 {starters.map((starter, index) => (
346 <div key={index} className="flex items-center gap-1.5">
347 <input
348 value={starter}
349 onChange={event => setStarterAt(index, event.target.value)}
350 placeholder={t('agent.builder.starterPlaceholder')}
351 className="w-full rounded-lg border border-shell-hairline bg-transparent px-2.5 py-1.5 text-[13px] text-shell-ink outline-none placeholder:text-shell-ink-muted"
352 />
353 {starters.length > 1 ? (
354 <button
355 type="button"
356 onClick={() => setStarters(current => current.filter((_, position) => position !== index))}
357 title={t('common.action.remove')}
358 aria-label={t('common.action.remove')}
359 className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-shell-ink-muted hover:bg-shell-active hover:text-shell-ink"
360 >
361 <X className="h-3.5 w-3.5" aria-hidden />
362 </button>
363 ) : null}
364 </div>
365 ))}
366 </div>
367 {starters.length < MAX_STARTERS ? (
368 <button
369 type="button"
370 onClick={() => setStarters(current => [...current, ''])}
371 className="mt-2 flex items-center gap-1 text-[12px] text-shell-ink-muted transition-colors hover:text-shell-ink"
372 >
373 <Plus className="h-3.5 w-3.5" aria-hidden />
374 {t('agent.builder.startersAdd')}
375 </button>
376 ) : null}
377 </div>
378 </div>
379 </section>
380 </div>
381
382 <footer className="flex shrink-0 items-center justify-end gap-2 border-t border-shell-hairline p-4">
383 <button
384 type="button"
385 onClick={onClose}
386 disabled={creating}
387 className="rounded-lg px-3 py-2 text-[13px] text-shell-ink-muted transition-colors hover:text-shell-ink disabled:opacity-50"
388 >
389 {t('agent.builder.discard')}
390 </button>
391 <button
392 type="button"
393 onClick={() => void create()}
394 disabled={creating || busy}
395 className="flex items-center gap-1.5 rounded-lg bg-shell-ink px-3.5 py-2 text-[13px] font-semibold text-shell-surface transition-opacity hover:opacity-90 disabled:opacity-60"
396 >
397 {creating ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
398 {t('agent.builder.create')}
399 </button>
400 </footer>
401 </aside>
402 </div>
403 </div>
404 </div>
405 )
406}
No newline at end of file
Added web/src/pages/workspace/ui/agent/agent-chat.tsx web/src/pages/workspace/ui/agent/agent-chat.tsx +839 −0 View web/src/pages/workspace/ui/agent/agent-chat.tsx
@@ -0,0 +1,839 @@
1import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2import { ArrowDown, ArrowUp, Check, ChevronDown, FolderGit2, Loader2, Plug, Search, Square } from 'lucide-react'
3import { useT } from '@/shared/i18n'
4import { ApiFailure, useAsync } from '@/shared/lib'
5import {
6 answerAgentQuestions,
7 cancelAgentRun,
8 createAgentStreamToken,
9 decideAgentApproval,
10 fetchAgentApprovals,
11 fetchAgentMessages,
12 fetchAgentQuestions,
13 fetchAgentRuns,
14 fetchAgentToolCalls,
15 sendAgentMessage,
16 type AgentApprovalDto,
17 type AgentDto,
18 type AgentMessageDto,
19 type AgentQuestionDto,
20 type AgentRunDto,
21 type AgentToolCallDto,
22} from '../../model/agent-api'
23import { fetchSpaceRepos } from '../../model/shell-api'
24import type { RepoItem } from '../../model/workspace'
25import { AgentStreamingRow, AgentThinkingRow, AgentTranscript, AgentWorkingRow, type ArtifactAccess } from './agent-message'
26
27export interface AgentChatProps {
28 spaceName: string
29 collectionId: string
30 agent: AgentDto
31 /** Space-management users may attach the platform MCP to a run. */
32 canEnableMcp: boolean
33}
34
35const ACTIVE_RUN_STATES = new Set(['pending', 'queued', 'running', 'waiting'])
36
37/** Run-event kinds that should re-pull authoritative state (debounced). */
38const STREAM_EVENT_KINDS = [
39 'run_started',
40 'run_finished',
41 'run_error',
42 'run_suspended',
43 'run_resumed',
44 'message_finished',
45 'tool_call_finished',
46 'step_finished',
47 'usage_recorded',
48 'approval_requested',
49 'approval_decided',
50 'question_asked',
51 'question_answered',
52 'goal_resolved',
53 'compaction',
54 'log',
55] as const
56
57function errorMessage(error: unknown): string {
58 return error instanceof ApiFailure ? error.message : String(error)
59}
60
61/**
62 * One agent's long-lived conversation: transcript, composer, run status plus the
63 * runtime's approval/question prompts. Live output comes from the runtime's
64 * fanout SSE, which the browser connects to directly with a short-lived token
65 * minted by the main API (control plane stays gRPC); polling remains as the
66 * fallback when streaming is unavailable.
67 */
68export function AgentChat({ spaceName, collectionId, agent, canEnableMcp }: AgentChatProps) {
69 const { t } = useT()
70 const [messages, setMessages] = useState<AgentMessageDto[]>([])
71 const [runs, setRuns] = useState<AgentRunDto[]>([])
72 const [approvals, setApprovals] = useState<AgentApprovalDto[]>([])
73 const [questions, setQuestions] = useState<AgentQuestionDto[]>([])
74 const [toolCalls, setToolCalls] = useState<AgentToolCallDto[]>([])
75 const [draft, setDraft] = useState('')
76 const [mcpEnabled, setMcpEnabled] = useState(() => {
77 try {
78 return localStorage.getItem(`gitdata.agent.mcp.${agent.id}`) === '1'
79 } catch {
80 return false
81 }
82 })
83 const [repo, setRepo] = useState(() => {
84 try {
85 return localStorage.getItem(`gitdata.agent.repo.${agent.id}`) ?? ''
86 } catch {
87 return ''
88 }
89 })
90 const spaceRepos = useAsync(() => fetchSpaceRepos(spaceName), [spaceName])
91 // The stored binding applies only while the repo still exists in the loaded
92 // list; it stays trusted until the list lands so reloads do not flash.
93 const repoBound =
94 repo && (!spaceRepos.data || spaceRepos.data.some(item => item.name === repo)) ? repo : ''
95 const mcpActive = canEnableMcp && mcpEnabled
96 const [busy, setBusy] = useState(false)
97 const [error, setError] = useState<string | null>(null)
98 const [loaded, setLoaded] = useState(false)
99 const [streaming, setStreaming] = useState('')
100 const [streamingThink, setStreamingThink] = useState('')
101 const [sseLive, setSseLive] = useState(false)
102 const lastSequence = useRef(0)
103 const lastEventId = useRef<string | null>(null)
104 const activeRunIdRef = useRef<string | null>(null)
105 const refreshTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
106 const scrollRef = useRef<HTMLDivElement>(null)
107 const composerRef = useRef<HTMLTextAreaElement>(null)
108 const [atBottom, setAtBottom] = useState(true)
109 const atBottomRef = useRef(true)
110 const programmaticScroll = useRef(false)
111
112 const activeRun = useMemo(
113 () => runs.find(run => ACTIVE_RUN_STATES.has(run.status)),
114 [runs],
115 )
116 const pendingApprovals = useMemo(
117 () => approvals.filter(approval => approval.status === 'pending'),
118 [approvals],
119 )
120 // Tool messages carry the call id their result belongs to; the call record
121 // supplies the command, status and duration shown on the card.
122 const toolCallByCallId = useMemo(() => {
123 const map = new Map<string, AgentToolCallDto>()
124 for (const call of toolCalls) map.set(call.call_id, call)
125 return map
126 }, [toolCalls])
127
128 const streamGrant = useRef<{ base: string; token: string; expiresAt: number } | null>(null)
129 /**
130 * Artifact downloads go through the runtime's redirect endpoint, which signs
131 * a fresh 1h S3 URL per request; the transcript only ever holds
132 * `artifact://` links. The stream grant is cached and re-minted on expiry so
133 * inline SVG previews and download clicks share one token.
134 */
135 const artifactUrl = useCallback(
136 async (artifactId: string) => {
137 const now = Date.now()
138 let grant = streamGrant.current
139 if (!grant || grant.expiresAt - now < 60_000) {
140 const fresh = await createAgentStreamToken(spaceName, collectionId, agent.id)
141 grant = {
142 base: fresh.fanout_endpoint.replace(/\/+$/, ''),
143 token: fresh.token,
144 expiresAt: fresh.expires_at * 1000,
145 }
146 streamGrant.current = grant
147 }
148 return `${grant.base}/v1/artifacts/${encodeURIComponent(artifactId)}/download?token=${encodeURIComponent(grant.token)}`
149 },
150 [spaceName, collectionId, agent.id],
151 )
152
153 const artifactAccess = useMemo<ArtifactAccess>(
154 () => ({
155 download: artifactId => {
156 void artifactUrl(artifactId).then(
157 url => window.open(url, '_blank', 'noopener,noreferrer'),
158 err => setError(errorMessage(err)),
159 )
160 },
161 resolveUrl: artifactUrl,
162 }),
163 [artifactUrl],
164 )
165
166 const refresh = useCallback(async () => {
167 const after = lastSequence.current > 0 ? lastSequence.current : undefined
168 const fresh = await fetchAgentMessages(spaceName, collectionId, agent.id, after)
169 if (fresh.length > 0) {
170 lastSequence.current = Math.max(lastSequence.current, ...fresh.map(m => m.sequence))
171 setMessages(prev => {
172 const seen = new Set(prev.map(m => m.message_id))
173 return [...prev, ...fresh.filter(m => !seen.has(m.message_id))]
174 })
175 // 正文一旦落库就由消息行接管;思考只在运行时也把它落成消息时才清,
176 // 否则保留到最后(运行时不会持久化思考,它是本轮唯一记录)。
177 setStreaming('')
178 if (fresh.some(item => item.kind === 'reasoning')) setStreamingThink('')
179 }
180 const [nextRuns, nextApprovals, nextQuestions, nextToolCalls] = await Promise.all([
181 fetchAgentRuns(spaceName, collectionId, agent.id),
182 fetchAgentApprovals(spaceName, collectionId, agent.id),
183 fetchAgentQuestions(spaceName, collectionId, agent.id),
184 fetchAgentToolCalls(spaceName, collectionId, agent.id),
185 ])
186 setRuns(nextRuns)
187 setApprovals(nextApprovals)
188 setQuestions(nextQuestions)
189 setToolCalls(nextToolCalls)
190 }, [spaceName, collectionId, agent.id])
191
192 useEffect(() => {
193 // The component is keyed by agent id, so state starts clean on every switch.
194 let active = true
195 void refresh().then(
196 () => {
197 if (active) setLoaded(true)
198 },
199 err => {
200 if (active) {
201 setLoaded(true)
202 setError(errorMessage(err))
203 }
204 },
205 )
206 return () => {
207 active = false
208 }
209 }, [refresh])
210
211 const activeRunId = activeRun?.run_id
212
213 useEffect(() => {
214 activeRunIdRef.current = activeRunId ?? null
215 }, [activeRunId])
216
217 /** Coalesce SSE-driven refreshes: replays and token bursts must not stampede. */
218 const refreshSoon = useCallback(() => {
219 if (refreshTimer.current) return
220 refreshTimer.current = setTimeout(() => {
221 refreshTimer.current = undefined
222 void refresh().catch(() => {})
223 }, 400)
224 }, [refresh])
225
226 useEffect(
227 () => () => {
228 if (refreshTimer.current) clearTimeout(refreshTimer.current)
229 },
230 [],
231 )
232
233 // Direct fanout stream: mint a token, connect to /events + /tokens, fall back
234 // to polling on any failure (the polling effect below keeps the transcript
235 // and prompts fresh either way).
236 useEffect(() => {
237 let cancelled = false
238 let events: EventSource | null = null
239 let tokens: EventSource | null = null
240 let retry: ReturnType<typeof setTimeout> | undefined
241
242 const closeStreams = () => {
243 events?.close()
244 tokens?.close()
245 events = null
246 tokens = null
247 }
248
249 const onRunEvent = (event: MessageEvent) => {
250 if (event.lastEventId) lastEventId.current = event.lastEventId
251 let runId: string | null = null
252 try {
253 const envelope = JSON.parse(event.data) as { run_id?: string }
254 runId = envelope.run_id ?? null
255 } catch {
256 // A malformed frame must not break the stream.
257 }
258 if (event.type === 'run_started' && runId) {
259 activeRunIdRef.current = runId
260 setStreaming('')
261 setStreamingThink('')
262 } else if (
263 event.type === 'run_finished' ||
264 event.type === 'run_error' ||
265 event.type === 'run_suspended'
266 ) {
267 // 结束时只清正文流;思考保留为「思考过程」,下一轮 run_started 再清。
268 setStreaming('')
269 }
270 refreshSoon()
271 }
272
273 const connect = async () => {
274 try {
275 const grant = await createAgentStreamToken(spaceName, collectionId, agent.id)
276 if (cancelled) return
277 const base = `${grant.fanout_endpoint.replace(/\/+$/, '')}/v1/sessions/${encodeURIComponent(grant.conversation_id)}`
278 const token = encodeURIComponent(grant.token)
279 const after = lastEventId.current
280 ? `&after=${encodeURIComponent(lastEventId.current)}`
281 : ''
282 events = new EventSource(`${base}/events?token=${token}${after}`)
283 tokens = new EventSource(`${base}/tokens?token=${token}`)
284 events.onopen = () => {
285 if (!cancelled) setSseLive(true)
286 }
287 events.onerror = () => {
288 if (cancelled) return
289 setSseLive(false)
290 closeStreams()
291 retry = setTimeout(() => {
292 if (!cancelled) void connect()
293 }, 15_000)
294 }
295 for (const kind of STREAM_EVENT_KINDS) {
296 events.addEventListener(kind, onRunEvent)
297 }
298 tokens.addEventListener('token', event => {
299 const message = event as MessageEvent
300 if (message.lastEventId) lastEventId.current = message.lastEventId
301 let payload: {
302 token?: string
303 run_id?: string | null
304 agent_role?: string | null
305 reasoning?: boolean
306 }
307 try {
308 payload = JSON.parse(message.data)
309 } catch {
310 return
311 }
312 if (payload.agent_role || !payload.token) return
313 const runId = payload.run_id ?? null
314 if (runId && activeRunIdRef.current && runId !== activeRunIdRef.current) return
315 // 思维链与正文分开累积:runtime 在 token 上标记 reasoning。
316 if (payload.reasoning) setStreamingThink(prev => prev + payload.token)
317 else setStreaming(prev => prev + payload.token)
318 })
319 } catch {
320 // Streaming unavailable (runtime off / fanout not configured): polling only.
321 if (!cancelled) setSseLive(false)
322 }
323 }
324
325 void connect()
326 return () => {
327 cancelled = true
328 closeStreams()
329 if (retry) clearTimeout(retry)
330 }
331 }, [spaceName, collectionId, agent.id, refreshSoon])
332
333 useEffect(() => {
334 if (!activeRunId) return
335 const timer = setInterval(
336 () => {
337 void refresh().catch(() => {})
338 },
339 sseLive ? 5000 : 1500,
340 )
341 return () => clearInterval(timer)
342 }, [activeRunId, refresh, sseLive])
343
344 // Only stick to the bottom while the reader is already there; scrolling up
345 // pauses the auto-follow and reveals the jump-to-bottom button. The pin runs
346 // before paint so opening a conversation never flashes the top first.
347 useLayoutEffect(() => {
348 const node = scrollRef.current
349 if (node && atBottomRef.current) node.scrollTop = node.scrollHeight
350 }, [messages.length, activeRun?.run_id, streaming.length])
351
352 // Late content growth (images, markdown repaint) must not drift the reader
353 // who is parked at the bottom.
354 useEffect(() => {
355 const node = scrollRef.current
356 const target = node?.firstElementChild
357 if (!node || !target) return
358 const observer = new ResizeObserver(() => {
359 if (atBottomRef.current) node.scrollTop = node.scrollHeight
360 })
361 observer.observe(target)
362 return () => observer.disconnect()
363 }, [loaded])
364
365 const onScroll = () => {
366 if (programmaticScroll.current) return
367 const node = scrollRef.current
368 if (!node) return
369 const distance = node.scrollHeight - node.scrollTop - node.clientHeight
370 const next = distance < 48
371 atBottomRef.current = next
372 setAtBottom(next)
373 }
374
375 const scrollToBottom = useCallback(() => {
376 const node = scrollRef.current
377 if (!node) return
378 programmaticScroll.current = true
379 atBottomRef.current = true
380 setAtBottom(true)
381 node.scrollTo({ top: node.scrollHeight, behavior: 'smooth' })
382 setTimeout(() => {
383 programmaticScroll.current = false
384 }, 700)
385 }, [])
386
387 const send = async () => {
388 const text = draft.trim()
389 if (!text || busy) return
390 setBusy(true)
391 setError(null)
392 try {
393 await sendAgentMessage(spaceName, collectionId, agent.id, text, canEnableMcp && mcpEnabled, repoBound || null)
394 setDraft('')
395 scrollToBottom()
396 await refresh()
397 } catch (err) {
398 setError(errorMessage(err))
399 } finally {
400 setBusy(false)
401 }
402 }
403
404 const selectRepo = (next: string) => {
405 setRepo(next)
406 try {
407 const key = `gitdata.agent.repo.${agent.id}`
408 if (next) localStorage.setItem(key, next)
409 else localStorage.removeItem(key)
410 } catch {
411 // Preference stays in memory when storage is unavailable.
412 }
413 }
414
415 // A binding that no longer resolves (repo deleted or renamed) is pruned from
416 // storage; the chip already ignores it via `repoBound`.
417 useEffect(() => {
418 const items = spaceRepos.data
419 if (!items || !repo) return
420 if (items.some(item => item.name === repo)) return
421 try {
422 localStorage.removeItem(`gitdata.agent.repo.${agent.id}`)
423 } catch {
424 // Preference stays in memory when storage is unavailable.
425 }
426 }, [spaceRepos.data, repo, agent.id])
427
428 const toggleMcp = (next: boolean) => {
429 setMcpEnabled(next)
430 try {
431 const key = `gitdata.agent.mcp.${agent.id}`
432 if (next) localStorage.setItem(key, '1')
433 else localStorage.removeItem(key)
434 } catch {
435 // Preference stays in memory when storage is unavailable.
436 }
437 }
438
439 const stop = async () => {
440 if (!activeRun || busy) return
441 setBusy(true)
442 setError(null)
443 try {
444 await cancelAgentRun(spaceName, collectionId, agent.id, activeRun.run_id)
445 await refresh()
446 } catch (err) {
447 setError(errorMessage(err))
448 } finally {
449 setBusy(false)
450 }
451 }
452
453 const decide = async (approvalId: string, decision: 'approved' | 'rejected') => {
454 setError(null)
455 try {
456 await decideAgentApproval(spaceName, collectionId, agent.id, approvalId, { decision })
457 await refresh()
458 } catch (err) {
459 setError(errorMessage(err))
460 }
461 }
462
463 const answer = async (questionId: string, answers: { item_id: string; selected: string[] }[]) => {
464 setError(null)
465 try {
466 await answerAgentQuestions(spaceName, collectionId, agent.id, questionId, { answers })
467 await refresh()
468 } catch (err) {
469 setError(errorMessage(err))
470 }
471 }
472
473 return (
474 <div className="flex min-h-0 min-w-0 flex-1 flex-col">
475 {error ? (
476 <p role="alert" className="border-b border-[#e5484d]/20 bg-[#e5484d]/8 px-4 py-1.5 text-[12px] text-[#b42318]">
477 {error}
478 </p>
479 ) : null}
480
481 <div className="relative min-h-0 flex-1">
482 <div
483 ref={scrollRef}
484 onScroll={onScroll}
485 className="h-full overflow-y-auto px-4 py-4"
486 >
487 {!loaded ? (
488 <div className="flex h-full items-center justify-center">
489 <Loader2 className="h-5 w-5 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
490 </div>
491 ) : messages.length === 0 && !streaming ? (
492 <div className="mx-auto mt-10 flex max-w-md flex-col items-center gap-3 text-center">
493 <p className="text-[13px] leading-6 text-shell-ink-muted">
494 {t('agent.chat.empty', { name: agent.name })}
495 </p>
496 {agent.starters.length > 0 ? (
497 <div className="flex flex-wrap justify-center gap-2">
498 {agent.starters.map(starter => (
499 <button
500 key={starter}
501 type="button"
502 onClick={() => {
503 setDraft(starter)
504 composerRef.current?.focus()
505 }}
506 className="rounded-full border border-shell-hairline px-3 py-1.5 text-[12.5px] text-shell-ink-muted transition-colors hover:border-shell-tile hover:text-shell-ink"
507 >
508 {starter}
509 </button>
510 ))}
511 </div>
512 ) : null}
513 </div>
514 ) : (
515 <div className="mx-auto flex max-w-3xl flex-col gap-2">
516 <AgentTranscript
517 messages={messages}
518 toolCallByCallId={toolCallByCallId}
519 artifactAccess={artifactAccess}
520 />
521 {streamingThink ? <AgentThinkingRow text={streamingThink} live={Boolean(activeRun)} /> : null}
522 {streaming ? <AgentStreamingRow text={streaming} /> : null}
523 {activeRun && !streaming && !streamingThink ? <AgentWorkingRow /> : null}
524 </div>
525 )}
526 </div>
527 {!atBottom ? (
528 <button
529 type="button"
530 onClick={scrollToBottom}
531 title={t('agent.chat.scrollBottom')}
532 aria-label={t('agent.chat.scrollBottom')}
533 className="absolute bottom-4 right-4 flex h-8 w-8 items-center justify-center rounded-full border border-shell-hairline bg-shell-surface text-shell-ink-muted shadow-md transition-colors hover:text-shell-ink"
534 >
535 <ArrowDown className="h-4 w-4" />
536 </button>
537 ) : null}
538 </div>
539
540 {pendingApprovals.length > 0 ? (
541 <div className="mx-auto flex w-full max-w-3xl flex-col gap-2 px-4">
542 {pendingApprovals.map(approval => (
543 <div key={approval.approval_id} className="rounded-lg border border-shell-hairline bg-shell-surface p-3">
544 <p className="text-[13px] font-semibold text-shell-ink">
545 {t('agent.approval.title')}
546 </p>
547 <p className="mt-1 break-all text-[12px] leading-5 text-shell-ink-muted">
548 {approval.tool_name || approval.action} · {approval.scope}
549 {approval.reason ? ` · ${approval.reason}` : ''}
550 </p>
551 <div className="mt-2 flex gap-2">
552 <button
553 type="button"
554 onClick={() => void decide(approval.approval_id, 'approved')}
555 className="rounded-md bg-[#1a7f37] px-3 py-1 text-[12px] font-semibold text-white hover:opacity-90"
556 >
557 {t('agent.approval.approve')}
558 </button>
559 <button
560 type="button"
561 onClick={() => void decide(approval.approval_id, 'rejected')}
562 className="rounded-md bg-[#e5484d] px-3 py-1 text-[12px] font-semibold text-white hover:opacity-90"
563 >
564 {t('agent.approval.reject')}
565 </button>
566 </div>
567 </div>
568 ))}
569 </div>
570 ) : null}
571
572 {questions
573 .filter(question => question.status === 'pending')
574 .map(question => (
575 <QuestionCard
576 key={question.question_id}
577 question={question}
578 onSubmit={answers => void answer(question.question_id, answers)}
579 />
580 ))}
581
582 <div className="px-4 pb-4 pt-1">
583 <div className="mx-auto max-w-3xl">
584 <div className="flex flex-col rounded-2xl border border-shell-hairline bg-shell-surface shadow-[0_1px_2px_rgba(16,24,40,0.04)] transition-colors focus-within:border-shell-ink/20">
585 <textarea
586 ref={composerRef}
587 value={draft}
588 onChange={event => setDraft(event.target.value)}
589 onKeyDown={event => {
590 if (event.key === 'Enter' && !event.shiftKey) {
591 event.preventDefault()
592 void send()
593 }
594 }}
595 rows={2}
596 placeholder={t('agent.chat.placeholder', { name: agent.name })}
597 className="max-h-40 min-h-[56px] w-full resize-none bg-transparent px-4 pb-1 pt-3 text-[13px] leading-6 text-shell-ink outline-none placeholder:text-shell-ink-muted"
598 />
599 <div className="flex items-center gap-2 px-3 pb-2.5">
600 <button
601 type="button"
602 role="switch"
603 aria-checked={mcpActive}
604 disabled={!canEnableMcp}
605 title={canEnableMcp ? t('agent.chat.mcp.hint') : t('agent.chat.mcp.forbidden')}
606 onClick={() => toggleMcp(!mcpEnabled)}
607 className={`flex h-8 shrink-0 items-center gap-2 rounded-full border px-3 text-[12.5px] transition-colors ${
608 mcpActive
609 ? 'border-[#c7d2fe] bg-[#eef2ff] text-[#4d6bfe] dark:border-[#3b4a86] dark:bg-[#1e2547] dark:text-[#9db1ff]'
610 : canEnableMcp
611 ? 'border-shell-hairline text-shell-ink-muted hover:bg-shell-active/50 hover:text-shell-ink'
612 : 'cursor-not-allowed border-shell-hairline text-shell-ink-muted/50'
613 }`}
614 >
615 <Plug className="h-4 w-4" aria-hidden />
616 {t('agent.chat.inline')}
617 </button>
618 <RepoPicker repos={spaceRepos.data ?? []} value={repoBound} onChange={selectRepo} />
619 {activeRun ? (
620 <p className="min-w-0 flex-1 truncate text-[11px] leading-4 text-shell-ink-muted">
621 {t('agent.chat.running', { status: activeRun.status })}
622 </p>
623 ) : (
624 <span className="flex-1" />
625 )}
626 {activeRun ? (
627 <button
628 type="button"
629 onClick={() => void stop()}
630 disabled={busy}
631 title={t('agent.chat.stop')}
632 aria-label={t('agent.chat.stop')}
633 className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#e5484d] text-white transition-opacity hover:opacity-90 disabled:opacity-50"
634 >
635 <Square className="h-3.5 w-3.5" />
636 </button>
637 ) : (
638 <button
639 type="button"
640 onClick={() => void send()}
641 disabled={busy || draft.trim() === ''}
642 title={t('agent.chat.send')}
643 aria-label={t('agent.chat.send')}
644 className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-shell-ink text-shell-surface transition-opacity hover:opacity-90 disabled:bg-shell-field disabled:text-shell-ink-muted"
645 >
646 <ArrowUp className="h-4 w-4" />
647 </button>
648 )}
649 </div>
650 </div>
651 </div>
652 </div>
653 </div>
654 )
655}
656
657
658function QuestionCard({
659 question,
660 onSubmit,
661}: {
662 question: AgentQuestionDto
663 onSubmit: (answers: { item_id: string; selected: string[] }[]) => void
664}) {
665 const { t } = useT()
666 const [selected, setSelected] = useState<Record<string, string[]>>({})
667
668 const toggle = (itemId: string, label: string, multiple: boolean) => {
669 setSelected(current => {
670 const picked = current[itemId] ?? []
671 if (!multiple) return { ...current, [itemId]: [label] }
672 return {
673 ...current,
674 [itemId]: picked.includes(label) ? picked.filter(v => v !== label) : [...picked, label],
675 }
676 })
677 }
678
679 return (
680 <div className="mx-auto w-full max-w-3xl px-4 pb-2">
681 <div className="rounded-lg border border-shell-hairline bg-shell-surface p-3">
682 <p className="text-[13px] font-semibold text-shell-ink">{t('agent.question.title')}</p>
683 {question.questions.map(item => (
684 <div key={item.item_id} className="mt-2">
685 <p className="text-[12.5px] leading-5 text-shell-ink/90">
686 {item.header ? `${item.header}: ` : ''}
687 {item.question}
688 </p>
689 <div className="mt-1 flex flex-wrap gap-1.5">
690 {item.options.map(option => {
691 const active = (selected[item.item_id] ?? []).includes(option.label)
692 return (
693 <button
694 key={option.label}
695 type="button"
696 onClick={() => toggle(item.item_id, option.label, item.multiple)}
697 title={option.description ?? undefined}
698 className={`rounded-full border px-2.5 py-1 text-[12px] ${
699 active
700 ? 'border-shell-ink bg-shell-active text-shell-ink'
701 : 'border-shell-hairline text-shell-ink/70 hover:bg-shell-active/60'
702 }`}
703 >
704 {option.label}
705 </button>
706 )
707 })}
708 </div>
709 </div>
710 ))}
711 <button
712 type="button"
713 onClick={() =>
714 onSubmit(
715 question.questions
716 .map(item => ({ item_id: item.item_id, selected: selected[item.item_id] ?? [] }))
717 .filter(answer => answer.selected.length > 0),
718 )
719 }
720 className="mt-3 rounded-md bg-shell-ink px-3 py-1 text-[12px] font-semibold text-shell-surface hover:opacity-90"
721 >
722 {t('agent.question.submit')}
723 </button>
724 </div>
725 </div>
726 )
727}
728/**
729 * Compact repository chooser for the composer: the chip mirrors the repo
730 * toolbar's ref picker, and the popover lists every repository of the space
731 * (plus an unbind option) with a filter for long lists.
732 */
733function RepoPicker({
734 repos,
735 value,
736 onChange,
737}: {
738 repos: RepoItem[]
739 value: string
740 onChange: (next: string) => void
741}) {
742 const { t } = useT()
743 const [open, setOpen] = useState(false)
744 const [query, setQuery] = useState('')
745 const rootRef = useRef<HTMLDivElement>(null)
746
747 useEffect(() => {
748 if (!open) return
749 const onDown = (event: PointerEvent) => {
750 if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
751 }
752 const onKey = (event: KeyboardEvent) => {
753 if (event.key === 'Escape') setOpen(false)
754 }
755 window.addEventListener('pointerdown', onDown)
756 window.addEventListener('keydown', onKey)
757 return () => {
758 window.removeEventListener('pointerdown', onDown)
759 window.removeEventListener('keydown', onKey)
760 }
761 }, [open])
762
763 const items = repos.filter(item => item.name.toLowerCase().includes(query.trim().toLowerCase()))
764 const optionClass = (selected: boolean) =>
765 `flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[13px] transition-colors hover:bg-shell-active ${
766 selected ? 'font-semibold' : ''
767 }`
768
769 return (
770 <div ref={rootRef} className="relative flex shrink-0 items-center">
771 <button
772 type="button"
773 aria-haspopup="dialog"
774 aria-expanded={open}
775 title={t('agent.chat.repo.hint')}
776 onClick={() => {
777 setQuery('')
778 setOpen(current => !current)
779 }}
780 className="flex h-8 max-w-[220px] items-center gap-2 rounded-md border border-transparent px-2.5 text-[12.5px] text-shell-ink-muted transition-colors hover:border-shell-hairline hover:text-shell-ink"
781 >
782 <FolderGit2 className="h-4 w-4 shrink-0" aria-hidden />
783 <span className="truncate">{value || t('agent.chat.repo.none')}</span>
784 <ChevronDown className="h-3.5 w-3.5 shrink-0" aria-hidden />
785 </button>
786 {open ? (
787 <div
788 role="dialog"
789 aria-label={t('agent.chat.repo.hint')}
790 className="absolute bottom-full left-0 z-40 mb-1.5 w-64 rounded-lg border border-shell-hairline bg-shell-surface p-2 shadow-xl"
791 >
792 <div className="flex items-center gap-2 rounded-lg border border-shell-hairline bg-shell-field px-2.5 py-1.5">
793 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
794 <input
795 autoFocus
796 aria-label={t('agent.chat.repo.search')}
797 value={query}
798 onChange={event => setQuery(event.target.value)}
799 placeholder={t('agent.chat.repo.search')}
800 className="w-full min-w-0 bg-transparent text-[13px] leading-6 outline-none"
801 />
802 </div>
803 <div role="listbox" aria-label={t('agent.chat.repo.none')} className="max-h-56 overflow-y-auto pt-1">
804 <button
805 type="button"
806 role="option"
807 aria-selected={value === ''}
808 onClick={() => {
809 onChange('')
810 setOpen(false)
811 }}
812 className={optionClass(value === '')}
813 >
814 <span className="truncate text-shell-ink-muted">{t('agent.chat.repo.none')}</span>
815 {value === '' ? <Check className="ml-auto h-4 w-4 shrink-0 text-shell-ink" aria-hidden /> : null}
816 </button>
817 {items.map(item => (
818 <button
819 key={item.name}
820 type="button"
821 role="option"
822 aria-selected={item.name === value}
823 onClick={() => {
824 onChange(item.name)
825 setOpen(false)
826 }}
827 className={optionClass(item.name === value)}
828 >
829 <FolderGit2 className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" aria-hidden />
830 <span className="truncate">{item.name}</span>
831 {item.name === value ? <Check className="ml-auto h-4 w-4 shrink-0 text-shell-ink" aria-hidden /> : null}
832 </button>
833 ))}
834 </div>
835 </div>
836 ) : null}
837 </div>
838 )
839}
No newline at end of file
Added web/src/pages/workspace/ui/agent/agent-message.tsx web/src/pages/workspace/ui/agent/agent-message.tsx +1243 −0 View web/src/pages/workspace/ui/agent/agent-message.tsx
@@ -0,0 +1,1243 @@
1import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2import {
3 Braces,
4 Check,
5 ChevronDown,
6 Copy,
7 Download,
8 File,
9 FileArchive,
10 FilePen,
11 FileSpreadsheet,
12 FileText,
13 FolderOpen,
14 LineChart,
15 Loader2,
16 Plug,
17 Terminal,
18} from 'lucide-react'
19import { formatBytes, formatTime, useT, type MessageKey, type TranslateFn } from '@/shared/i18n'
20import { Markdown } from '@/shared/ui'
21import type { AgentMessageDto, AgentToolCallDto } from '../../model/agent-api'
22
23/**
24 * One transcript row. Runtime messages arrive as flat text: assistant turns are
25 * Markdown, tool turns are the tool's rendered output (usually a JSON document
26 * such as a shell result or an artifact upload). The renderer keeps raw payloads
27 * out of the flow of reading: every tool turn becomes a compact activity card
28 * with a semantic title, a one-line summary and an expandable body.
29 */
30export function AgentMessageView({
31 message,
32 toolCall,
33 artifacts,
34 artifactAccess,
35 final,
36}: {
37 message: AgentMessageDto
38 /** Runtime record for tool turns: the command/path, status and duration. */
39 toolCall?: AgentToolCallDto
40 /** Artifact index for resolving `artifact://` links inside assistant text. */
41 artifacts: TranscriptArtifacts
42 artifactAccess?: ArtifactAccess
43 final?: boolean
44}) {
45 if (message.kind === 'compaction') return <CompactionDivider />
46 if (message.role === 'user') return <UserRow message={message} />
47 if (message.role === 'tool') {
48 return message.content.trim() ? (
49 <ToolRow
50 raw={message.content}
51 time={message.created_at}
52 call={toolCall}
53 artifactAccess={artifactAccess}
54 />
55 ) : null
56 }
57 if (message.role !== 'assistant') return null
58 // Tool-only turns carry no text; an empty bubble would only add noise.
59 if (!message.content.trim()) return null
60 if (message.kind === 'reasoning') {
61 return <ReasoningRow message={message} />
62 }
63 return (
64 <AssistantRow
65 content={message.content}
66 time={message.created_at}
67 artifacts={artifacts}
68 artifactAccess={artifactAccess}
69 final={final}
70 />
71 )
72}
73
74
75function CopyButton({ text }: { text: string }) {
76 const { t } = useT()
77 const [copied, setCopied] = useState(false)
78 return (
79 <button
80 type="button"
81 title={copied ? t('agent.msg.copied') : t('agent.msg.copy')}
82 onClick={() => {
83 const writing = navigator.clipboard?.writeText(text)
84 if (!writing) return
85 void writing.then(
86 () => {
87 setCopied(true)
88 setTimeout(() => setCopied(false), 1500)
89 },
90 () => {},
91 )
92 }}
93 className="flex h-5 w-5 items-center justify-center rounded text-shell-ink-muted hover:bg-shell-active hover:text-shell-ink"
94 >
95 {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
96 </button>
97 )
98}
99
100function UserRow({ message }: { message: AgentMessageDto }) {
101 return (
102 <div className="flex justify-end">
103 <div className="group relative max-w-[85%]">
104 <div className="whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-shell-active px-3.5 py-2 text-[13px] leading-6 text-shell-ink">
105 {message.content}
106 </div>
107 <div className="pointer-events-none absolute right-0 top-full flex items-center gap-1.5 pt-1 text-[11px] text-shell-ink-muted opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
108 <span>{formatTime(message.created_at)}</span>
109 <CopyButton text={message.content} />
110 </div>
111 </div>
112 </div>
113 )
114}
115
116/** Code spans holding a storage URI or `artifact://` link become downloads. */
117function resolveArtifactText(
118 text: string,
119 artifacts: TranscriptArtifacts,
120): { id: string; label: string } | null {
121 const trimmed = text.trim()
122 const direct = artifactIdOf(trimmed)
123 if (direct) return { id: direct, label: artifacts.byId.get(direct) ?? trimmed }
124 const ref = artifacts.byKey.get(trimmed)
125 if (ref) return { id: ref.id, label: ref.name ?? trimmed }
126 return null
127}
128
129function AssistantRow({
130 content,
131 time,
132 artifacts,
133 artifactAccess,
134 final,
135}: {
136 content: string
137 time: number
138 artifacts: TranscriptArtifacts
139 artifactAccess?: ArtifactAccess
140 /** Only the conversation's last message keeps the time + copy footer. */
141 final?: boolean
142}) {
143 const bodyRef = useRef<HTMLDivElement>(null)
144 // Markdown cannot linkify text the model wrapped in code spans; rewrite the
145 // rendered nodes instead so legacy `s3://` cells become download links too.
146 // Relative chart images (``) are re-pointed at the artifact.
147 useLayoutEffect(() => {
148 const root = bodyRef.current
149 if (!root) return
150 let active = true
151 for (const node of root.querySelectorAll('code')) {
152 const resolved = resolveArtifactText(node.textContent ?? '', artifacts)
153 if (!resolved) continue
154 const anchor = document.createElement('a')
155 anchor.href = `#artifact-${resolved.id}`
156 anchor.textContent = resolved.label
157 node.replaceWith(anchor)
158 }
159 for (const image of root.querySelectorAll('img')) {
160 const src = image.getAttribute('src') ?? ''
161 const ref = src.startsWith('http') || src.startsWith('data:')
162 ? null
163 : artifacts.byName.get(src.split('/').pop() ?? '')
164 if (!ref) {
165 // Relative images that match no artifact would render as broken icons.
166 if (!src.startsWith('http') && !src.startsWith('data:')) image.style.display = 'none'
167 continue
168 }
169 image.style.opacity = '0'
170 if (artifactAccess) {
171 void artifactAccess.resolveUrl(ref.id).then(
172 url => {
173 if (!active) return
174 image.src = url
175 image.style.opacity = '1'
176 },
177 () => {
178 image.style.opacity = '1'
179 },
180 )
181 } else {
182 image.style.opacity = '1'
183 }
184 }
185 return () => {
186 active = false
187 }
188 }, [content, artifacts, artifactAccess])
189 return (
190 <div className="min-w-0">
191 <div className="min-w-0">
192 <div
193 ref={bodyRef}
194 onClick={event => {
195 if (!artifactAccess) return
196 const anchor = event.target instanceof Element ? event.target.closest('a') : null
197 const href = anchor?.getAttribute('href') ?? ''
198 if (!href.startsWith('#artifact-')) return
199 event.preventDefault()
200 artifactAccess.download(href.slice('#artifact-'.length))
201 }}
202 >
203 <Markdown>{linkArtifacts(content, artifacts)}</Markdown>
204 </div>
205 </div>
206 {final ? (
207 <div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-shell-ink-muted">
208 <span>{formatTime(time)}</span>
209 <CopyButton text={content} />
210 </div>
211 ) : null}
212 </div>
213 )
214}
215
216function ReasoningRow({ message }: { message: AgentMessageDto }) {
217 const { t } = useT()
218 const [open, setOpen] = useState(false)
219 return (
220 <div className="min-w-0">
221 <button
222 type="button"
223 onClick={() => setOpen(value => !value)}
224 className="flex items-center gap-1.5 text-[11px] leading-4 text-shell-ink-muted hover:text-shell-ink"
225 >
226 <span>{t('agent.msg.reasoning')}</span>
227 <span>{formatTime(message.created_at)}</span>
228 <ChevronDown className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`} />
229 </button>
230 {open ? (
231 <div className="mt-1.5 whitespace-pre-wrap break-words rounded-lg border border-dashed border-shell-hairline px-3 py-2 text-[12.5px] leading-5 text-shell-ink-muted">
232 {message.content}
233 </div>
234 ) : null}
235 </div>
236 )
237}
238
239function CompactionDivider() {
240 const { t } = useT()
241 return (
242 <div className="flex items-center gap-3 text-[11px] text-shell-ink-muted">
243 <span className="h-px flex-1 bg-shell-hairline" />
244 <span>{t('agent.msg.compaction')}</span>
245 <span className="h-px flex-1 bg-shell-hairline" />
246 </div>
247 )
248}
249
250/** 正文流:与最终消息同排版,只多一个光标;内容持久化后由消息行接管。 */
251export function AgentStreamingRow({ text }: { text: string }) {
252 return (
253 <div className="min-w-0 whitespace-pre-wrap break-words text-[13px] leading-6 text-shell-ink">
254 {text}
255 <span className="ml-0.5 inline-block h-3.5 w-[2px] translate-y-[2px] animate-pulse bg-shell-ink-muted" />
256 </div>
257 )
258}
259
260/**
261 * 思维链流:灰字虚线卡片,与正文/工具卡明确区分。流式期间显示「思考中」+
262 * 转圈,运行结束后保留为「思考过程」(运行时不落库时它就是本轮的唯一记录),
263 * 直到下一轮开始或出现持久化的思考消息。
264 */
265export function AgentThinkingRow({ text, live }: { text: string; live: boolean }) {
266 const { t } = useT()
267 const [open, setOpen] = useState(true)
268 return (
269 <div className="min-w-0">
270 <button
271 type="button"
272 onClick={() => setOpen(value => !value)}
273 className="flex items-center gap-1.5 text-[11px] leading-4 text-shell-ink-muted hover:text-shell-ink"
274 >
275 {live ? (
276 <Loader2 className="h-3 w-3 animate-spin" aria-hidden />
277 ) : (
278 <span className="h-1.5 w-1.5 rounded-full bg-current opacity-60" aria-hidden />
279 )}
280 <span>{live ? t('agent.chat.thinking') : t('agent.msg.reasoning')}</span>
281 <ChevronDown className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`} />
282 </button>
283 {open ? (
284 <div className="mt-1.5 whitespace-pre-wrap break-words rounded-lg border border-dashed border-shell-hairline px-3 py-2 text-[12.5px] leading-5 text-shell-ink-muted">
285 {text}
286 <span className="ml-0.5 inline-block h-3 w-[2px] translate-y-[2px] animate-pulse bg-shell-ink-muted" />
287 </div>
288 ) : null}
289 </div>
290 )
291}
292
293export function AgentWorkingRow() {
294 const { t } = useT()
295 return (
296 <div className="flex items-center gap-2 text-[11.5px] text-shell-ink-muted">
297 <span className="flex items-center gap-0.5">
298 {[0, 1, 2].map(index => (
299 <span
300 key={index}
301 className="h-1 w-1 animate-bounce rounded-full bg-shell-ink-muted"
302 style={{ animationDelay: `${index * 120}ms` }}
303 />
304 ))}
305 </span>
306 <span>{t('agent.msg.working')}</span>
307 </div>
308 )
309}
310
311type ToolBudget = { text: string; percent: string | null }
312
313type ToolView =
314 | { kind: 'terminal'; exit: number | null; stdout: string; stderr: string; truncated: boolean }
315 | { kind: 'write'; path: string; bytes: number | null }
316 | { kind: 'edit'; path: string; replacements: number | null }
317 | {
318 kind: 'read'
319 path: string
320 content: string
321 offset: number | null
322 totalLines: number | null
323 truncated: boolean
324 }
325 | { kind: 'listing'; path: string; entries: string[] }
326 | {
327 kind: 'artifact'
328 id: string
329 name: string
330 mime: string | null
331 size: number | null
332 /** Chart pixel size, reserved before the image loads so nothing shifts. */
333 width: number | null
334 height: number | null
335 note: string | null
336 chart: boolean
337 }
338 | { kind: 'json'; pretty: string; summary: string }
339 | { kind: 'text'; text: string }
340
341type ToolPresentation = {
342 icon: React.ReactNode
343 title: string
344 chips: Array<{ text: string; tone: 'neutral' | 'success' | 'danger' }>
345 summary: string
346}
347
348function isRecord(value: unknown): value is Record<string, unknown> {
349 return typeof value === 'object' && value !== null && !Array.isArray(value)
350}
351
352function firstLine(text: string): string | null {
353 for (const line of text.split('\n')) {
354 const trimmed = line.trim()
355 if (trimmed) return trimmed
356 }
357 return null
358}
359
360const BUDGET_RE = /[\r\n]+\s*\[Context Budget:([^\]\n]*)\]\s*$/
361
362function splitBudget(raw: string): { body: string; budget: ToolBudget | null } {
363 const match = BUDGET_RE.exec(raw)
364 if (!match) return { body: raw, budget: null }
365 const text = match[1].trim()
366 const percent = /(\d+(?:\.\d+)?%)/.exec(text)?.[1] ?? null
367 return { body: raw.slice(0, match.index), budget: { text, percent } }
368}
369
370const ARTIFACT_ID = /^[0-9a-fA-F-]{36}$/
371
372/** Accepts `artifact://<uuid>`, the legacy `artifact:u'<uuid>'` and raw uuids. */
373function artifactIdOf(value: unknown): string | null {
374 if (typeof value !== 'string') return null
375 const bare = value
376 .trim()
377 .replace(/^artifact:\/\//, '')
378 .replace(/^artifact:/, '')
379 .replace(/^u'/, '')
380 .replace(/'$/, '')
381 return ARTIFACT_ID.test(bare) ? bare : null
382}
383
384/**
385 * Artifact results collapse to one card shape. Both the placeholder output
386 * (`artifact://…`) and the legacy output (storage key + signed URL) carry the
387 * id; the legacy entry points still resolve so old transcripts keep working.
388 */
389function artifactView(record: Record<string, unknown>): ToolView | null {
390 const nested = isRecord(record.artifact) ? record.artifact : null
391 const source = nested ?? record
392 const id =
393 artifactIdOf(source.artifact) ??
394 artifactIdOf(source.artifact_id) ??
395 artifactIdOf(record.artifact)
396 if (!id) return null
397 const series = Array.isArray(record.series)
398 ? record.series.filter((item): item is string => typeof item === 'string')
399 : []
400 return {
401 kind: 'artifact',
402 id,
403 name:
404 typeof source.name === 'string'
405 ? source.name
406 : typeof source.artifact_name === 'string'
407 ? source.artifact_name
408 : typeof source.path === 'string'
409 ? source.path
410 : 'file',
411 mime: typeof source.mime_type === 'string' ? source.mime_type : null,
412 size: typeof source.size_bytes === 'number' ? source.size_bytes : null,
413 width: typeof record.width === 'number' ? record.width : null,
414 height: typeof record.height === 'number' ? record.height : null,
415 note: series.length > 0 ? series.join(' · ') : null,
416 chart: nested !== null,
417 }
418}
419
420function fromRecord(record: Record<string, unknown>): ToolView | null {
421 const artifact = artifactView(record)
422 if (artifact) return artifact
423 if ('exit_code' in record || (typeof record.stdout === 'string' && typeof record.stderr === 'string')) {
424 return {
425 kind: 'terminal',
426 exit: typeof record.exit_code === 'number' ? record.exit_code : null,
427 stdout: typeof record.stdout === 'string' ? record.stdout : '',
428 stderr: typeof record.stderr === 'string' ? record.stderr : '',
429 truncated: record.truncated === true,
430 }
431 }
432 if (typeof record.path === 'string' && typeof record.bytes_written === 'number') {
433 return { kind: 'write', path: record.path, bytes: record.bytes_written }
434 }
435 if (typeof record.path === 'string' && typeof record.replacements === 'number') {
436 return { kind: 'edit', path: record.path, replacements: record.replacements }
437 }
438 if (typeof record.path === 'string' && typeof record.content === 'string') {
439 return {
440 kind: 'read',
441 path: record.path,
442 content: record.content,
443 offset: typeof record.offset === 'number' ? record.offset : null,
444 totalLines: typeof record.total_lines === 'number' ? record.total_lines : null,
445 truncated: record.truncated === true,
446 }
447 }
448 if (typeof record.path === 'string' && Array.isArray(record.entries)) {
449 return {
450 kind: 'listing',
451 path: record.path,
452 entries: record.entries.filter((entry): entry is string => typeof entry === 'string'),
453 }
454 }
455 return null
456}
457
458function parseToolContent(raw: string): { view: ToolView; budget: ToolBudget | null } {
459 const { body, budget } = splitBudget(raw)
460 const trimmed = body.trim()
461 let parsed: unknown
462 let parsedOk = false
463 if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
464 try {
465 parsed = JSON.parse(trimmed)
466 parsedOk = true
467 } catch {
468 parsedOk = false
469 }
470 }
471 if (parsedOk) {
472 if (isRecord(parsed)) {
473 const view = fromRecord(parsed)
474 if (view) return { view, budget }
475 }
476 const pretty = JSON.stringify(parsed, null, 2) ?? String(parsed)
477 const summary = Array.isArray(parsed)
478 ? `[${parsed.length}]`
479 : isRecord(parsed)
480 ? Object.keys(parsed).slice(0, 4).join(', ')
481 : pretty.split('\n')[0]
482 return { view: { kind: 'json', pretty, summary }, budget }
483 }
484 return { view: { kind: 'text', text: body }, budget }
485}
486
487function presentation(view: ToolView, t: TranslateFn): ToolPresentation {
488 switch (view.kind) {
489 case 'terminal':
490 return {
491 icon: <Terminal className="h-3.5 w-3.5" />,
492 title: t('agent.msg.terminal'),
493 chips:
494 view.exit === null
495 ? []
496 : [
497 {
498 text: t('agent.msg.terminal.exit', { code: view.exit }),
499 tone: view.exit === 0 ? 'success' : 'danger',
500 },
501 ],
502 summary:
503 firstLine(view.stdout) ?? firstLine(view.stderr) ?? t('agent.msg.noOutput'),
504 }
505 case 'write':
506 return {
507 icon: <FilePen className="h-3.5 w-3.5" />,
508 title: t('agent.msg.fileWrite'),
509 chips: view.bytes === null ? [] : [{ text: `+${formatBytes(view.bytes)}`, tone: 'neutral' }],
510 summary: view.path,
511 }
512 case 'edit':
513 return {
514 icon: <FilePen className="h-3.5 w-3.5" />,
515 title: t('agent.msg.fileEdit'),
516 chips:
517 view.replacements === null
518 ? []
519 : [
520 {
521 text: t('agent.msg.fileEdit.replacements', { count: view.replacements }),
522 tone: 'neutral',
523 },
524 ],
525 summary: view.path,
526 }
527 case 'read':
528 return {
529 icon: <FileText className="h-3.5 w-3.5" />,
530 title: t('agent.msg.fileRead'),
531 chips:
532 view.totalLines === null
533 ? []
534 : [
535 {
536 text: t('agent.msg.fileRead.range', {
537 offset: view.offset ?? 1,
538 lines: view.totalLines,
539 }),
540 tone: 'neutral',
541 },
542 ],
543 summary: firstLine(view.content) ?? view.path,
544 }
545 case 'listing':
546 return {
547 icon: <FolderOpen className="h-3.5 w-3.5" />,
548 title: t('agent.msg.dirList'),
549 chips: [
550 { text: t('agent.msg.dirList.entries', { count: view.entries.length }), tone: 'neutral' },
551 ],
552 summary: view.path,
553 }
554 case 'artifact':
555 return {
556 icon: view.chart ? <LineChart className="h-3.5 w-3.5" /> : <FileText className="h-3.5 w-3.5" />,
557 title: view.name,
558 chips: [],
559 summary: view.note ?? '',
560 }
561 case 'json':
562 return {
563 icon: <Braces className="h-3.5 w-3.5" />,
564 title: t('agent.msg.json'),
565 chips: [],
566 summary: view.summary,
567 }
568 case 'text':
569 return {
570 icon: <Terminal className="h-3.5 w-3.5" />,
571 title: t('agent.msg.output'),
572 chips: [],
573 summary: firstLine(view.text) ?? t('agent.msg.noOutput'),
574 }
575 }
576}
577
578function Pre({ children }: { children: string }) {
579 return (
580 <pre className="max-h-[340px] overflow-auto whitespace-pre-wrap break-words font-mono text-[11.5px] leading-5 text-shell-ink/85">
581 {children}
582 </pre>
583 )
584}
585
586function ToolBody({ view, budget }: { view: ToolView; budget: ToolBudget | null }) {
587 const { t } = useT()
588 return (
589 <div className="flex flex-col gap-2">
590 {view.kind === 'terminal' ? (
591 <>
592 {view.stderr.trim() ? (
593 <pre className="max-h-[240px] overflow-auto whitespace-pre-wrap break-words rounded-md border border-[#e5484d]/25 bg-[#e5484d]/8 px-2.5 py-2 font-mono text-[11.5px] leading-5 text-[#b42318]">
594 {view.stderr}
595 </pre>
596 ) : null}
597 {view.stdout.trim() ? <Pre>{view.stdout}</Pre> : null}
598 {!view.stdout.trim() && !view.stderr.trim() ? (
599 <p className="text-[12px] text-shell-ink-muted">{t('agent.msg.noOutput')}</p>
600 ) : null}
601 {view.truncated ? (
602 <p className="text-[11px] text-shell-ink-muted">{t('agent.msg.truncated')}</p>
603 ) : null}
604 </>
605 ) : null}
606 {view.kind === 'read' ? (
607 <>
608 {view.content.trim() ? (
609 <Pre>{view.content}</Pre>
610 ) : (
611 <p className="text-[12px] text-shell-ink-muted">{t('agent.msg.emptyFile')}</p>
612 )}
613 {view.truncated ? (
614 <p className="text-[11px] text-shell-ink-muted">{t('agent.msg.truncated')}</p>
615 ) : null}
616 </>
617 ) : null}
618 {view.kind === 'listing' ? (
619 view.entries.length > 0 ? (
620 <Pre>{view.entries.join('\n')}</Pre>
621 ) : (
622 <p className="text-[12px] text-shell-ink-muted">{t('agent.msg.emptyDir')}</p>
623 )
624 ) : null}
625 {view.kind === 'json' ? <Pre>{view.pretty}</Pre> : null}
626 {view.kind === 'text' ? <Pre>{view.text}</Pre> : null}
627 {budget?.percent ? (
628 <p className="text-[10.5px] text-shell-ink-muted" title={`[Context Budget: ${budget.text}]`}>
629 {t('agent.msg.context', { percent: budget.percent })}
630 </p>
631 ) : null}
632 </div>
633 )
634}
635
636/** Tool titles keyed by the runtime's tool name; unknown names use the result shape. */
637const TOOL_TITLE_KEYS: Record<string, MessageKey> = {
638 exec: 'agent.msg.exec',
639 fs_read: 'agent.msg.fileRead',
640 fs_write: 'agent.msg.fileWrite',
641 fs_edit: 'agent.msg.fileEdit',
642 fs_list: 'agent.msg.dirList',
643 'fs.read': 'agent.msg.fileRead',
644 'fs.write': 'agent.msg.fileWrite',
645 'fs.edit': 'agent.msg.fileEdit',
646 'fs.list': 'agent.msg.dirList',
647}
648
649function parseCallInput(raw: string | null | undefined): Record<string, unknown> | null {
650 if (!raw) return null
651 try {
652 const parsed: unknown = JSON.parse(raw)
653 return isRecord(parsed) ? parsed : null
654 } catch {
655 return null
656 }
657}
658
659/** Quote an argv entry only when the shell would otherwise split or expand it. */
660function shellArg(arg: string): string {
661 return /[\s"'\\$`]/.test(arg) ? `'${arg.replace(/'/g, `'\\''`)}'` : arg
662}
663
664/** The one-line call description shown collapsed: the command, or the path. */
665function summarizeCall(call: AgentToolCallDto): string | null {
666 const input = parseCallInput(call.input)
667 if (!input) return null
668 if (typeof input.command === 'string') {
669 const args = Array.isArray(input.args)
670 ? input.args.filter((arg): arg is string => typeof arg === 'string')
671 : []
672 return [input.command, ...args].map(shellArg).join(' ')
673 }
674 if (typeof input.path === 'string') return input.path
675 return null
676}
677
678function formatDuration(ms: number): string {
679 if (ms < 1000) return `${Math.max(1, Math.round(ms))}ms`
680 const seconds = ms / 1000
681 if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`
682 const minutes = Math.floor(seconds / 60)
683 return `${minutes}m${Math.round(seconds % 60)
684 .toString()
685 .padStart(2, '0')}s`
686}
687
688
689type ArtifactRef = { id: string; name: string | null }
690
691type ArtifactItem = {
692 id: string
693 name: string
694 mime: string | null
695 size: number | null
696 /** Run that produced the artifact — deliverables render at that run's end. */
697 runId: string | null
698}
699
700/** id -> display name, plus legacy storage-key/URI lookups for old transcripts. */
701type TranscriptArtifacts = {
702 byId: Map<string, string | null>
703 byKey: Map<string, ArtifactRef>
704 byName: Map<string, ArtifactRef>
705 /** Registration order, deduplicated: drives the end-of-transcript grid. */
706 list: ArtifactItem[]
707}
708
709/** Download/redirect access to the runtime, injected by the chat container. */
710export type ArtifactAccess = {
711 /** Opens the runtime's redirect endpoint in a new tab. */
712 download: (artifactId: string) => void
713 /** Ready-to-use URL (redirect endpoint + token) for inline previews. */
714 resolveUrl: (artifactId: string) => Promise<string>
715}
716
717function collectArtifacts(messages: AgentMessageDto[]): TranscriptArtifacts {
718 const byId = new Map<string, string | null>()
719 const byKey = new Map<string, ArtifactRef>()
720 const byName = new Map<string, ArtifactRef>()
721 const list: ArtifactItem[] = []
722 const seen = new Set<string>()
723 for (const message of messages) {
724 if (message.role !== 'tool' || !message.content.trim()) continue
725 let parsed: unknown
726 try {
727 // Tool results carry a trailing context-budget reminder; strip it first.
728 parsed = JSON.parse(splitBudget(message.content).body.trim())
729 } catch {
730 continue
731 }
732 if (!isRecord(parsed)) continue
733 const records = isRecord(parsed.artifact) ? [parsed, parsed.artifact] : [parsed]
734 for (const record of records) {
735 const id = artifactIdOf(record.artifact) ?? artifactIdOf(record.artifact_id)
736 if (!id) continue
737 const name =
738 typeof record.name === 'string'
739 ? record.name
740 : typeof record.artifact_name === 'string'
741 ? record.artifact_name
742 : null
743 if (!byId.has(id)) byId.set(id, name)
744 const ref: ArtifactRef = { id, name }
745 if (typeof record.storage_key === 'string') byKey.set(record.storage_key, ref)
746 if (typeof record.uri === 'string') byKey.set(record.uri, ref)
747 if (name && !byName.has(name)) byName.set(name, ref)
748 if (!seen.has(id)) {
749 seen.add(id)
750 list.push({
751 id,
752 name: name ?? id,
753 mime: typeof record.mime_type === 'string' ? record.mime_type : null,
754 size: typeof record.size_bytes === 'number' ? record.size_bytes : null,
755 runId: message.run_id ?? null,
756 })
757 }
758 }
759 }
760 return { byId, byKey, byName, list }
761}
762
763function artifactLabel(text: string): string {
764 return `\`${text.replace(/`/g, '')}\``
765}
766
767/**
768 * Rewrites artifact references into `#artifact-<id>` anchors. Assistant rows
769 * intercept those clicks and fetch a fresh signed download, so neither the
770 * model output nor the page payload has to carry storage URLs.
771 */
772function linkArtifacts(markdown: string, artifacts: TranscriptArtifacts): string {
773 return markdown
774 .split('`')
775 .map((segment, index) => (index % 2 === 1 ? segment : linkArtifactChunk(segment, artifacts)))
776 .join('`')
777}
778
779function linkArtifactChunk(chunk: string, artifacts: TranscriptArtifacts): string {
780 return chunk
781 .replace(/\]\(artifact:\/\/([0-9a-fA-F-]{36})\)/g, '](#artifact-$1)')
782 .replace(/artifact:\/\/([0-9a-fA-F-]{36})/g, (_match, id: string) => {
783 const name = artifacts.byId.get(id) ?? null
784 return `[${artifactLabel(name ?? id)}](#artifact-${id})`
785 })
786 .replace(/s3:\/\/([^\s/]+)\/([^\s)\]]+)/g, (match, _bucket: string, key: string) => {
787 const ref = artifacts.byKey.get(key)
788 return ref ? `[${artifactLabel(ref.name ?? key)}](#artifact-${ref.id})` : match
789 })
790}
791
792function isImageName(mime: string | null, name: string): boolean {
793 if ((mime ?? '').startsWith('image/')) return true
794 return /\.(svg|png|jpe?g|gif|webp)$/i.test(name)
795}
796
797function isImageArtifact(view: Extract<ToolView, { kind: 'artifact' }>): boolean {
798 return isImageName(view.mime, view.name)
799}
800
801/**
802 * Image artifacts render frameless inside the transcript. Clicking the chart
803 * opens an enlarged overlay; a standalone download button sits underneath.
804 */
805function ArtifactFigure({
806 artifactId,
807 name,
808 access,
809 width,
810 height,
811}: {
812 artifactId: string
813 name: string
814 access: ArtifactAccess
815 width: number | null
816 height: number | null
817}) {
818 const { t } = useT()
819 const [src, setSrc] = useState<string | null>(null)
820 const [zoomed, setZoomed] = useState(false)
821 useEffect(() => {
822 let active = true
823 void access.resolveUrl(artifactId).then(
824 url => {
825 if (active) setSrc(url)
826 },
827 () => {},
828 )
829 return () => {
830 active = false
831 }
832 }, [artifactId, access])
833 useEffect(() => {
834 if (!zoomed) return
835 const onKey = (event: KeyboardEvent) => {
836 if (event.key === 'Escape') setZoomed(false)
837 }
838 window.addEventListener('keydown', onKey)
839 return () => window.removeEventListener('keydown', onKey)
840 }, [zoomed])
841 // The box is sized from the chart's declared pixels (or a 2:1 default) so the
842 // image can stream in without moving anything below it.
843 const ratio = width && height ? `${width} / ${height}` : '2 / 1'
844 return (
845 <div className="flex flex-col items-start gap-2">
846 <button
847 type="button"
848 onClick={() => {
849 if (src) setZoomed(true)
850 }}
851 className="block w-full cursor-zoom-in"
852 title={name}
853 >
854 <span
855 className={`block w-full overflow-hidden ${
856 src ? '' : 'animate-pulse rounded-lg bg-shell-field/40'
857 }`}
858 style={{ aspectRatio: ratio }}
859 >
860 {src ? <img src={src} alt={name} className="h-full w-full object-contain" /> : null}
861 </span>
862 </button>
863 <button
864 type="button"
865 onClick={() => access.download(artifactId)}
866 className="flex h-7 items-center gap-1 rounded-md border border-shell-hairline px-2 text-[11.5px] font-medium text-shell-ink hover:bg-shell-active"
867 >
868 <Download className="h-3 w-3" />
869 {t('agent.msg.artifact.download')}
870 </button>
871 {zoomed && src ? (
872 <div
873 role="dialog"
874 aria-label={name}
875 onClick={() => setZoomed(false)}
876 className="fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/60 p-6"
877 >
878 <img src={src} alt={name} className="max-h-[88vh] max-w-[92vw] rounded-lg bg-white shadow-2xl" />
879 </div>
880 ) : null}
881 </div>
882 )
883}
884
885function ToolRow({
886 raw,
887 time,
888 call,
889 artifactAccess,
890}: {
891 raw: string
892 time: number
893 call?: AgentToolCallDto
894 artifactAccess?: ArtifactAccess
895}) {
896 const { t } = useT()
897 const { view, budget } = useMemo(() => parseToolContent(raw), [raw])
898 const [open, setOpen] = useState(false)
899 const head = useMemo(() => presentation(view, t), [view, t])
900 const callTitle = call ? TOOL_TITLE_KEYS[call.name] : undefined
901 // MCP tools carry `mcp.<server>.<tool>` names; the server-qualified tool
902 // reads better than the generic JSON-output fallback.
903 const mcpName = call?.name.startsWith('mcp.') ? call.name.slice(4) : null
904 const title = mcpName ? t('agent.msg.mcpCall') : callTitle ? t(callTitle) : head.title
905 const summary = mcpName ?? (call ? summarizeCall(call) : null) ?? head.summary
906 const duration = call?.duration_ms != null ? formatDuration(call.duration_ms) : null
907 // Command runs read like Codex: "ran in 2.1s <command>". The result of a
908 // timed-out run is plain text, so the tool name is the reliable signal.
909 const commandRun = view.kind === 'terminal' || call?.name === 'exec'
910 const commandPrefix =
911 commandRun && duration ? t('agent.msg.ranIn', { duration }) : null
912 const chips: Array<{ text: string; tone: 'neutral' | 'success' | 'danger' }> = head.chips.filter(
913 chip => !(commandPrefix && chip.tone === 'success'),
914 )
915 if (duration && !commandPrefix) chips.push({ text: duration, tone: 'neutral' })
916 if (chips.length === 0 && call?.status === 'failed') {
917 chips.push({ text: t('agent.msg.failed'), tone: 'danger' })
918 }
919
920 if (view.kind === 'artifact') {
921 if (isImageArtifact(view)) {
922 return artifactAccess ? (
923 <ArtifactFigure
924 artifactId={view.id}
925 name={view.name}
926 access={artifactAccess}
927 width={view.width}
928 height={view.height}
929 />
930 ) : null
931 }
932 // Non-image deliverables are collected into the end-of-transcript grid.
933 return null
934 }
935 return (
936 <div className="flex flex-col">
937 <button
938 type="button"
939 aria-expanded={open}
940 onClick={() => setOpen(value => !value)}
941 className="flex w-full items-center gap-2 rounded-lg px-1.5 py-1 text-left hover:bg-shell-active/40"
942 >
943 <span className="flex h-5 w-5 shrink-0 items-center justify-center text-shell-ink-muted">
944 {head.icon}
945 </span>
946 {commandPrefix ? (
947 <span className="flex min-w-0 flex-1 items-center gap-2">
948 <span className="shrink-0 text-[11.5px] text-shell-ink-muted">{commandPrefix}</span>
949 {summary ? (
950 <span
951 className="min-w-0 truncate font-mono text-[11.5px] text-shell-ink/85"
952 title={summary}
953 >
954 {summary}
955 </span>
956 ) : null}
957 </span>
958 ) : (
959 <span className="flex min-w-0 flex-1 items-center gap-2">
960 <span className="shrink-0 text-[12px] font-medium text-shell-ink">{title}</span>
961 {summary ? (
962 <span
963 className="min-w-0 truncate font-mono text-[11.5px] text-shell-ink-muted"
964 title={summary}
965 >
966 {summary}
967 </span>
968 ) : null}
969 </span>
970 )}
971 {chips.map(chip => (
972 <span
973 key={chip.text}
974 className={`shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-medium ${
975 chip.tone === 'success'
976 ? 'bg-[#1a7f37]/10 text-[#1a7f37]'
977 : chip.tone === 'danger'
978 ? 'bg-[#e5484d]/10 text-[#b42318]'
979 : 'bg-shell-field text-shell-ink-muted'
980 }`}
981 >
982 {chip.text}
983 </span>
984 ))}
985 <span className="shrink-0 text-[10.5px] text-shell-ink-muted">{formatTime(time)}</span>
986 <ChevronDown
987 className={`h-3.5 w-3.5 shrink-0 text-shell-ink-muted transition-transform ${
988 open ? 'rotate-180' : ''
989 }`}
990 />
991 </button>
992 {open ? (
993 <div className="mb-1 ml-7 rounded-lg border border-shell-hairline bg-shell-field/40 px-2.5 py-2">
994 <ToolBody view={view} budget={budget} />
995 </div>
996 ) : null}
997 </div>
998 )
999}
1000
1001/**
1002 * Consecutive tool turns share one collapsible header, the way Codex batches
1003 * shell work: "ran commands" over the individual `ran in Ns <command>` rows.
1004 */
1005function ToolGroup({
1006 rows,
1007 toolCallByCallId,
1008 artifactAccess,
1009}: {
1010 rows: AgentMessageDto[]
1011 toolCallByCallId: Map<string, AgentToolCallDto>
1012 artifactAccess?: ArtifactAccess
1013}) {
1014 const { t } = useT()
1015 const [open, setOpen] = useState(false)
1016 const mcpOnly =
1017 rows.length > 0 &&
1018 rows.every(row => {
1019 const call = row.external_call_id ? toolCallByCallId.get(row.external_call_id) : undefined
1020 return call?.name.startsWith('mcp.') ?? false
1021 })
1022 return (
1023 <div className="flex flex-col">
1024 <button
1025 type="button"
1026 aria-expanded={open}
1027 onClick={() => setOpen(value => !value)}
1028 className="flex w-fit items-center gap-1.5 rounded-md px-1.5 py-1 text-[11.5px] text-shell-ink-muted hover:bg-shell-active/40 hover:text-shell-ink"
1029 >
1030 {mcpOnly ? <Plug className="h-3.5 w-3.5" /> : <Terminal className="h-3.5 w-3.5" />}
1031 <span>{mcpOnly ? t('agent.msg.mcpCalls') : t('agent.msg.ranCommands')}</span>
1032 <ChevronDown className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`} />
1033 </button>
1034 {open ? (
1035 <div className="flex flex-col pl-5">
1036 {rows.map(row => (
1037 <ToolRow
1038 key={row.message_id}
1039 raw={row.content}
1040 time={row.created_at}
1041 call={
1042 row.external_call_id ? toolCallByCallId.get(row.external_call_id) : undefined
1043 }
1044 artifactAccess={artifactAccess}
1045 />
1046 ))}
1047 </div>
1048 ) : null}
1049 </div>
1050 )
1051}
1052
1053function fileKind(
1054 item: ArtifactItem,
1055 t: TranslateFn,
1056): { label: string; icon: React.ReactNode; accent: string } {
1057 const extension = item.name.split('.').pop()?.toLowerCase() ?? ''
1058 const mime = item.mime ?? ''
1059 if (mime.includes('zip') || ['zip', 'tar', 'gz', 'tgz'].includes(extension)) {
1060 return {
1061 label: t('agent.msg.fileKind.archive'),
1062 icon: <FileArchive className="h-4 w-4" />,
1063 accent: 'bg-[#f59e0b]/10 text-[#b45309]',
1064 }
1065 }
1066 if (extension === 'md' || mime.includes('markdown')) {
1067 return {
1068 label: t('agent.msg.fileKind.markdown'),
1069 icon: <FileText className="h-4 w-4" />,
1070 accent: 'bg-[#3b82f6]/10 text-[#1d4ed8]',
1071 }
1072 }
1073 if (extension === 'csv' || extension === 'tsv' || mime.includes('csv')) {
1074 return {
1075 label: t('agent.msg.fileKind.table'),
1076 icon: <FileSpreadsheet className="h-4 w-4" />,
1077 accent: 'bg-[#10b981]/10 text-[#047857]',
1078 }
1079 }
1080 if (mime.startsWith('text/') || extension === 'txt') {
1081 return {
1082 label: t('agent.msg.fileKind.text'),
1083 icon: <FileText className="h-4 w-4" />,
1084 accent: 'bg-[#64748b]/10 text-[#475569]',
1085 }
1086 }
1087 return {
1088 label: extension ? extension.toUpperCase() : t('agent.msg.fileKind.file'),
1089 icon: <File className="h-4 w-4" />,
1090 accent: 'bg-shell-field text-shell-ink-muted',
1091 }
1092}
1093
1094/**
1095 * Manus-style deliverables: every non-image artifact of the conversation,
1096 * collected into a card grid at the very end of the transcript.
1097 */
1098function ArtifactDeliverables({
1099 items,
1100 access,
1101}: {
1102 items: ArtifactItem[]
1103 access: ArtifactAccess
1104}) {
1105 const { t } = useT()
1106 return (
1107 <div aria-label={t('agent.msg.deliverables')} className="grid grid-cols-1 gap-2 sm:grid-cols-2">
1108 {items.map(item => {
1109 const kind = fileKind(item, t)
1110 return (
1111 <button
1112 key={item.id}
1113 type="button"
1114 onClick={() => access.download(item.id)}
1115 title={item.name}
1116 className="flex items-center gap-2.5 rounded-xl border border-shell-hairline bg-shell-surface px-3 py-2.5 text-left hover:bg-shell-active/40"
1117 >
1118 <span
1119 className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${kind.accent}`}
1120 >
1121 {kind.icon}
1122 </span>
1123 <span className="min-w-0 flex-1">
1124 <span className="block truncate text-[12.5px] font-medium text-shell-ink">
1125 {item.name}
1126 </span>
1127 <span className="block truncate text-[11px] text-shell-ink-muted">
1128 {[kind.label, item.size === null ? null : formatBytes(item.size)]
1129 .filter(Boolean)
1130 .join(' · ')}
1131 </span>
1132 </span>
1133 <Download className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" />
1134 </button>
1135 )
1136 })}
1137 </div>
1138 )
1139}
1140
1141type TranscriptItem =
1142 | { kind: 'message'; message: AgentMessageDto }
1143 | { kind: 'tools'; key: string; rows: AgentMessageDto[] }
1144
1145function isVisibleMessage(message: AgentMessageDto): boolean {
1146 if (message.kind === 'compaction') return true
1147 if (message.role === 'user') return true
1148 if (message.role === 'assistant') return message.content.trim().length > 0
1149 return false
1150}
1151
1152function buildTranscript(messages: AgentMessageDto[]): TranscriptItem[] {
1153 const items: TranscriptItem[] = []
1154 let batch: AgentMessageDto[] = []
1155 const flush = () => {
1156 if (batch.length > 0) {
1157 items.push({ kind: 'tools', key: `tools-${batch[0].message_id}`, rows: batch })
1158 batch = []
1159 }
1160 }
1161 for (const message of messages) {
1162 if (message.role === 'tool' && message.content.trim()) {
1163 // Deliverable uploads keep their standalone file card instead of joining
1164 // a command batch.
1165 if (parseToolContent(message.content).view.kind === 'artifact') {
1166 flush()
1167 items.push({ kind: 'message', message })
1168 } else {
1169 batch.push(message)
1170 }
1171 continue
1172 }
1173 if (!isVisibleMessage(message)) continue
1174 flush()
1175 items.push({ kind: 'message', message })
1176 }
1177 flush()
1178 return items
1179}
1180
1181/** The transcript: text turns render as messages, tool stretches as batches. */
1182export function AgentTranscript({
1183 messages,
1184 toolCallByCallId,
1185 artifactAccess,
1186}: {
1187 messages: AgentMessageDto[]
1188 toolCallByCallId: Map<string, AgentToolCallDto>
1189 artifactAccess?: ArtifactAccess
1190}) {
1191 const items = useMemo(() => buildTranscript(messages), [messages])
1192 const artifacts = useMemo(() => collectArtifacts(messages), [messages])
1193 // Deliverables are grouped per run so each finished round carries its own
1194 // file grid instead of one pile at the very bottom of the transcript.
1195 const deliverablesByRun = useMemo(() => {
1196 const map = new Map<string, ArtifactItem[]>()
1197 for (const item of artifacts.list) {
1198 if (isImageName(item.mime, item.name)) continue
1199 const key = item.runId ?? ''
1200 const bucket = map.get(key)
1201 if (bucket) bucket.push(item)
1202 else map.set(key, [item])
1203 }
1204 return map
1205 }, [artifacts])
1206 const runOf = (item: TranscriptItem) =>
1207 (item.kind === 'tools' ? item.rows[0]?.run_id : item.message.run_id) ?? ''
1208 return (
1209 <>
1210 {items.map((item, index) => {
1211 const runId = runOf(item)
1212 const endOfRun = index === items.length - 1 || runOf(items[index + 1]) !== runId
1213 const runDeliverables = endOfRun ? deliverablesByRun.get(runId) : undefined
1214 return (
1215 <Fragment key={item.kind === 'tools' ? item.key : item.message.message_id}>
1216 {item.kind === 'tools' ? (
1217 <ToolGroup
1218 rows={item.rows}
1219 toolCallByCallId={toolCallByCallId}
1220 artifactAccess={artifactAccess}
1221 />
1222 ) : (
1223 <AgentMessageView
1224 message={item.message}
1225 artifacts={artifacts}
1226 final={index === items.length - 1}
1227 toolCall={
1228 item.message.external_call_id
1229 ? toolCallByCallId.get(item.message.external_call_id)
1230 : undefined
1231 }
1232 artifactAccess={artifactAccess}
1233 />
1234 )}
1235 {artifactAccess && runDeliverables && runDeliverables.length > 0 ? (
1236 <ArtifactDeliverables items={runDeliverables} access={artifactAccess} />
1237 ) : null}
1238 </Fragment>
1239 )
1240 })}
1241 </>
1242 )
1243}
No newline at end of file
Added web/src/pages/workspace/ui/agent/agent-view.tsx web/src/pages/workspace/ui/agent/agent-view.tsx +223 −0 View web/src/pages/workspace/ui/agent/agent-view.tsx
@@ -0,0 +1,223 @@
1import { useState } from 'react'
2import { Bot, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'
3import { useT } from '@/shared/i18n'
4import { ApiFailure, useAsync } from '@/shared/lib'
5import type { AgentCollectionItem } from '../../model/workspace'
6import {
7 deleteAgent,
8 fetchAgentCollection,
9 fetchAgents,
10 type AgentDto,
11} from '../../model/agent-api'
12import { ChatLayout } from '../layout/chat-layout'
13import { AgentBuilderModal } from './agent-builder-modal'
14import { AgentChat } from './agent-chat'
15
16export interface AgentViewProps {
17 spaceName: string
18 collectionId: string
19 /** Row from the sidebar's agent list — titles the frame before the fetch lands. */
20 collectionHint?: AgentCollectionItem
21 /** AppShell slots owned by the workspace frame. */
22 shell: {
23 rail?: React.ReactNode
24 sideHeader?: React.ReactNode
25 navPinned?: React.ReactNode
26 nav?: React.ReactNode
27 sideFooter?: React.ReactNode
28 }
29 /** An agent was added/removed — the shell refetches its lists. */
30 onChanged: () => void
31}
32
33function goneMessage(error: unknown): string | null {
34 if (error instanceof ApiFailure && error.code === 404) return error.message
35 return error instanceof ApiFailure ? error.message : error ? String(error) : null
36}
37
38/**
39 * Route-level agent-set page: the set's agents on the left, one agent's
40 * runtime conversation on the right. Every runtime call is proxied by the main
41 * API over gRPC; this page never reaches sandbox-agent directly.
42 */
43export function AgentView({ spaceName, collectionId, collectionHint, shell, onChanged }: AgentViewProps) {
44 const { t } = useT()
45 const [reload, setReload] = useState(0)
46 const [creating, setCreating] = useState(false)
47 const [selectedId, setSelectedId] = useState<string | null>(null)
48 const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
49 const [error, setError] = useState<string | null>(null)
50
51 const collection = useAsync(
52 () => fetchAgentCollection(spaceName, collectionId),
53 [spaceName, collectionId, reload],
54 )
55 const agents = useAsync(() => fetchAgents(spaceName, collectionId), [spaceName, collectionId, reload])
56
57 // No effect needed: an explicit choice wins, otherwise the first agent is the
58 // selected one — deleting the selected agent falls back on its own.
59 const selected =
60 agents.data?.find(agent => agent.id === selectedId) ?? agents.data?.[0]
61 const title = collection.data?.name || collectionHint?.name || t('agent.titleFallback')
62
63 const remove = async (agent: AgentDto) => {
64 setError(null)
65 try {
66 await deleteAgent(spaceName, collectionId, agent.id)
67 setConfirmDelete(null)
68 setReload(key => key + 1)
69 onChanged()
70 } catch (err) {
71 setError(goneMessage(err))
72 }
73 }
74
75 return (
76 <ChatLayout
77 {...shell}
78 title={title}
79 topic={
80 <span className="text-[12px] text-shell-ink-muted">
81 {t('agent.subtitle', { count: collection.data?.agent_count ?? agents.data?.length ?? 0 })}
82 </span>
83 }
84 actions={
85 <div className="flex items-center gap-1">
86 <button
87 type="button"
88 onClick={() => setReload(key => key + 1)}
89 title={t('common.action.refresh')}
90 className="flex h-8 w-8 items-center justify-center rounded-md text-shell-ink-muted hover:bg-shell-active"
91 >
92 <RefreshCw className="h-4 w-4" />
93 </button>
94 <button
95 type="button"
96 onClick={() => setCreating(true)}
97 className="flex h-8 items-center gap-1.5 rounded-md bg-shell-ink px-2.5 text-[12.5px] font-semibold text-shell-surface hover:opacity-90"
98 >
99 <Plus className="h-3.5 w-3.5" />
100 {t('agent.create')}
101 </button>
102 </div>
103 }
104 >
105 {error ? (
106 <p role="alert" className="self-start px-3 py-2 text-[12px] text-[#b42318]">
107 {error}
108 </p>
109 ) : null}
110
111 {selected ? (
112 <AgentChat
113 key={selected.id}
114 spaceName={spaceName}
115 collectionId={collectionId}
116 agent={selected}
117 canEnableMcp={collection.data?.can_enable_mcp ?? false}
118 />
119 ) : (
120 <div className="flex min-h-0 flex-1 items-center justify-center">
121 <div className="max-w-sm text-center">
122 <Bot className="mx-auto h-8 w-8 text-shell-ink-muted" />
123 <p className="mt-3 text-[13px] leading-6 text-shell-ink-muted">
124 {t('agent.selectHint')}
125 </p>
126 </div>
127 </div>
128 )}
129
130 <aside className="flex w-[calc(var(--nav-rail)_+_var(--nav-side))] min-w-0 shrink-0 flex-col border-l border-shell-hairline">
131 <div className="min-h-0 flex-1 overflow-y-auto p-2">
132 {agents.loading && !agents.data ? (
133 <div className="flex justify-center py-6">
134 <Loader2 className="h-4 w-4 animate-spin text-shell-ink-muted" aria-label={t('ui.loading')} />
135 </div>
136 ) : (agents.data ?? []).length === 0 ? (
137 <p className="px-2 py-6 text-center text-[12.5px] leading-5 text-shell-ink-muted">
138 {t('agent.empty')}
139 </p>
140 ) : (
141 <ul className="flex flex-col gap-0.5">
142 {(agents.data ?? []).map(agent => {
143 const active = agent.id === selected?.id
144 return (
145 <li key={agent.id}>
146 <div
147 className={`group flex items-center gap-2 rounded-md px-2 py-1.5 ${
148 active ? 'bg-shell-active' : 'hover:bg-shell-active/60'
149 }`}
150 >
151 <button
152 type="button"
153 onClick={() => setSelectedId(agent.id)}
154 className="flex min-w-0 flex-1 items-center gap-2 text-left"
155 >
156 {agent.avatar_url ? (
157 <img
158 src={agent.avatar_url}
159 alt=""
160 className="h-4 w-4 shrink-0 rounded-full object-cover"
161 />
162 ) : (
163 <Bot className="h-4 w-4 shrink-0 text-shell-ink-muted" />
164 )}
165 <span className="truncate text-[13px] font-medium text-shell-ink">
166 {agent.name}
167 </span>
168 <span className="ml-auto shrink-0 text-[11px] text-shell-ink-muted">
169 #{agent.number}
170 </span>
171 </button>
172 {confirmDelete === agent.id ? (
173 <span className="flex shrink-0 gap-1">
174 <button
175 type="button"
176 onClick={() => void remove(agent)}
177 className="rounded bg-[#e5484d] px-1.5 text-[11px] font-semibold text-white"
178 >
179 {t('common.action.confirm')}
180 </button>
181 <button
182 type="button"
183 onClick={() => setConfirmDelete(null)}
184 className="rounded px-1 text-[11px] text-shell-ink-muted hover:bg-shell-active"
185 >
186 {t('common.action.cancel')}
187 </button>
188 </span>
189 ) : (
190 <button
191 type="button"
192 onClick={() => setConfirmDelete(agent.id)}
193 title={t('agent.delete')}
194 className="hidden h-6 w-6 shrink-0 items-center justify-center rounded text-shell-ink-muted hover:bg-shell-active group-hover:flex"
195 >
196 <Trash2 className="h-3.5 w-3.5" />
197 </button>
198 )}
199 </div>
200 </li>
201 )
202 })}
203 </ul>
204 )}
205 </div>
206 </aside>
207
208 <AgentBuilderModal
209 open={creating}
210 spaceName={spaceName}
211 collectionId={collectionId}
212 runtimeOnline={collection.data?.runtime_online ?? false}
213 onClose={() => setCreating(false)}
214 onCreated={agent => {
215 setCreating(false)
216 setSelectedId(agent.id)
217 setReload(key => key + 1)
218 onChanged()
219 }}
220 />
221 </ChatLayout>
222 )
223}
No newline at end of file
Modified web/src/shared/api/apiDefinitions.ts web/src/shared/api/apiDefinitions.ts +56 −0 View web/src/shared/api/apiDefinitions.ts
@@ -100,6 +100,50 @@
100100 * **Do not edit the file manually.**
101101 */
102102export default {
103 'agents.list_agent_collections': ['GET', '/agents/{space_name}/collections'],
104 'agents.create_agent_collection': ['POST', '/agents/{space_name}/collections'],
105 'agents.get_agent_collection': ['GET', '/agents/{space_name}/collections/{collection_id}'],
106 'agents.delete_agent_collection': ['DELETE', '/agents/{space_name}/collections/{collection_id}'],
107 'agents.update_agent_collection': ['PATCH', '/agents/{space_name}/collections/{collection_id}'],
108 'agents.list_agents': ['GET', '/agents/{space_name}/collections/{collection_id}/agents'],
109 'agents.create_agent': ['POST', '/agents/{space_name}/collections/{collection_id}/agents'],
110 'agents.get_agent': ['GET', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}'],
111 'agents.delete_agent': ['DELETE', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}'],
112 'agents.update_agent': ['PATCH', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}'],
113 'agents.list_agent_approvals': [
114 'GET',
115 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals'
116 ],
117 'agents.decide_agent_approval': [
118 'POST',
119 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/approvals/{approval_id}/decide'
120 ],
121 'agents.agent_avatar': ['GET', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/avatar'],
122 'agents.upload_agent_avatar': ['PUT', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/avatar'],
123 'agents.list_agent_messages': ['GET', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/messages'],
124 'agents.send_agent_message': ['POST', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/messages'],
125 'agents.list_agent_questions': [
126 'GET',
127 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions'
128 ],
129 'agents.answer_agent_questions': [
130 'POST',
131 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/questions/{question_id}/answer'
132 ],
133 'agents.list_agent_runs': ['GET', '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs'],
134 'agents.cancel_agent_run': [
135 'POST',
136 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/runs/{run_id}/cancel'
137 ],
138 'agents.create_agent_stream_token': [
139 'POST',
140 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/stream-token'
141 ],
142 'agents.list_agent_tool_calls': [
143 'GET',
144 '/agents/{space_name}/collections/{collection_id}/agents/{agent_id}/tool-calls'
145 ],
146 'agents.builder_chat': ['POST', '/agents/{space_name}/collections/{collection_id}/builder'],
103147 'ai.list_ai_languages': ['GET', '/ai/languages'],
104148 'auth.totp_disable': ['POST', '/auth/2fa/disable'],
105149 'auth.totp_enable': ['POST', '/auth/2fa/enable'],
@@ -240,6 +284,10 @@
240284 'imports.get_import': ['GET', '/imports/{space_name}/{job_id}'],
241285 'imports.cancel_import': ['POST', '/imports/{space_name}/{job_id}/cancel'],
242286 'imports.retry_import': ['POST', '/imports/{space_name}/{job_id}/retry'],
287 'issues.create_issue_attachment': ['POST', '/issues/attachments'],
288 'issues.delete_issue_attachment': ['DELETE', '/issues/attachments/{attachment_id}'],
289 'issues.download_issue_attachment': ['GET', '/issues/attachments/{attachment_id}/file'],
290 'issues.finalize_issue_attachment': ['POST', '/issues/attachments/{attachment_id}/finalize'],
243291 'issues.list_boards': ['GET', '/issues/{space_name}/boards'],
244292 'issues.create_board': ['POST', '/issues/{space_name}/boards'],
245293 'issues.get_board': ['GET', '/issues/{space_name}/boards/{board_id}'],
@@ -251,6 +299,7 @@
251299 'issues.get_issue': ['GET', '/issues/{space_name}/boards/{board_id}/issues/{issue_id}'],
252300 'issues.delete_issue': ['DELETE', '/issues/{space_name}/boards/{board_id}/issues/{issue_id}'],
253301 'issues.update_issue': ['PATCH', '/issues/{space_name}/boards/{board_id}/issues/{issue_id}'],
302 'issues.list_issue_attachments': ['GET', '/issues/{space_name}/boards/{board_id}/issues/{issue_id}/attachments'],
254303 'issues.close_issue': ['POST', '/issues/{space_name}/boards/{board_id}/issues/{issue_id}/close'],
255304 'issues.list_comments': ['GET', '/issues/{space_name}/boards/{board_id}/issues/{issue_id}/comments'],
256305 'issues.create_comment': ['POST', '/issues/{space_name}/boards/{board_id}/issues/{issue_id}/comments'],
@@ -459,6 +508,8 @@
459508 'pipelines.rerun': ['POST', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/rerun'],
460509 'pulls.list_pulls': ['GET', '/repos/{space_name}/{repo_name}/pulls'],
461510 'pulls.create_pull': ['POST', '/repos/{space_name}/{repo_name}/pulls'],
511 'pulls.ai_fill_pull': ['POST', '/repos/{space_name}/{repo_name}/pulls/ai-fill'],
512 'pulls.list_label_options': ['GET', '/repos/{space_name}/{repo_name}/pulls/label-options'],
462513 'pulls.get_pull': ['GET', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}'],
463514 'pulls.delete_pull': ['DELETE', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}'],
464515 'pulls.update_pull': ['PATCH', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}'],
@@ -575,6 +626,11 @@
575626 'space.get_ban': ['GET', '/spaces/{space_name}/bans/{user_id}'],
576627 'space.unban_user': ['DELETE', '/spaces/{space_name}/bans/{user_id}'],
577628 'space.update_ban': ['PATCH', '/spaces/{space_name}/bans/{user_id}'],
629 'cleanup.list_cleanup_objects': ['GET', '/spaces/{space_name}/cleanup'],
630 'cleanup.delete_cleanup_object': ['DELETE', '/spaces/{space_name}/cleanup/{kind}/{object_id}'],
631 'cleanup.set_cleanup_archive': ['POST', '/spaces/{space_name}/cleanup/{kind}/{object_id}/archive'],
632 'cleanup.clear_cleanup_overwrites': ['DELETE', '/spaces/{space_name}/cleanup/{kind}/{object_id}/overwrites'],
633 'cleanup.set_cleanup_visibility': ['POST', '/spaces/{space_name}/cleanup/{kind}/{object_id}/visibility'],
578634 'space.get_space_dir_tree': ['GET', '/spaces/{space_name}/dir'],
579635 'space.create_dir_node': ['POST', '/spaces/{space_name}/dir'],
580636 'space.repair_dir_parents': ['POST', '/spaces/{space_name}/dir/repair-parents'],
Modified web/src/shared/api/globals.d.ts web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts
Diff too large to display.
Added web/src/shared/i18n/messages/en/agent.ts web/src/shared/i18n/messages/en/agent.ts +99 −0 View web/src/shared/i18n/messages/en/agent.ts
@@ -0,0 +1,99 @@
1/** Agent sets and their sandbox-backed conversations. */
2export const agent = {
3 'agent.titleFallback': 'Agent set',
4 'agent.subtitle': '{count} agents',
5 'agent.create': 'New agent',
6 'agent.empty': 'This agent set has no agents yet — create one from the top right.',
7 'agent.selectHint': 'Pick an agent on the left to start talking, or create one first.',
8 'agent.delete': 'Delete agent',
9 'agent.chat.empty': 'Describe a task to “{name}”; it runs in a sandbox and reports back.',
10 'agent.chat.placeholder': 'Message {name}',
11 'agent.chat.send': 'Send',
12 'agent.chat.scrollBottom': 'Scroll to bottom',
13 'agent.chat.stop': 'Stop',
14 'agent.chat.running': 'Running ({status})…',
15 'agent.chat.inline': 'Inline mode',
16 'agent.chat.mcp.hint': 'Let the agent use this space’s repository, issue and pull request tools over MCP.',
17 'agent.chat.mcp.forbidden': 'Requires space management permission and a configured MCP endpoint.',
18 'agent.chat.repo.none': 'No repository',
19 'agent.chat.repo.hint': 'Bind a repository to this conversation; every run carries it in its system context.',
20 'agent.chat.repo.search': 'Search repositories',
21 'agent.builder.title': 'Agent builder',
22 'agent.builder.online': 'Runtime online',
23 'agent.builder.offline': 'Runtime offline',
24 'agent.builder.heroTitle': 'What should this agent do?',
25 'agent.builder.heroHint': 'Describe what this agent should do.',
26 'agent.builder.placeholder': 'Message the agent builder…',
27 'agent.builder.thinking': 'Generating…',
28 'agent.builder.chip.review': 'Review front-end pull requests',
29 'agent.builder.chip.research': 'Research competitors and summarize',
30 'agent.builder.chip.plan': 'Plan and write projects with the team',
31 'agent.builder.configTitle': 'Agent configuration',
32 'agent.builder.configHint': 'Edit the draft directly.',
33 'agent.builder.identity': 'Identity',
34 'agent.builder.avatar': 'Change avatar',
35 'agent.builder.name': 'Name',
36 'agent.builder.namePlaceholder': 'e.g. Deep research agent',
37 'agent.builder.description': 'Description',
38 'agent.builder.descriptionPlaceholder': 'What does this agent do?',
39 'agent.builder.behavior': 'Behavior & capability',
40 'agent.builder.instructions': 'Instructions',
41 'agent.builder.instructionsPlaceholder': 'What it should do, focus on and avoid…',
42 'agent.builder.starters': 'Conversation starters',
43 'agent.builder.startersHint': 'Up to three. Clicking one fills the input instead of sending.',
44 'agent.builder.starterPlaceholder': 'One starter',
45 'agent.builder.startersAdd': 'Add one',
46 'agent.builder.discard': 'Discard',
47 'agent.builder.create': 'Create & open',
48 'agent.builder.nameRequired': 'Enter an agent name first',
49 'agent.chat.streaming': 'live',
50 'agent.chat.thinking': 'Thinking',
51 'agent.chat.session': 'Session {id}',
52 'agent.chat.noSession': 'This agent has no runtime session yet.',
53 'agent.msg.copy': 'Copy',
54 'agent.msg.copied': 'Copied',
55 'agent.msg.reasoning': 'Thinking',
56 'agent.msg.working': 'Working',
57 'agent.msg.compaction': 'Context compacted',
58 'agent.msg.terminal': 'Terminal output',
59 'agent.msg.terminal.exit': 'exit {code}',
60 'agent.msg.exec': 'Ran command',
61 'agent.msg.ranCommands': 'Ran commands',
62 'agent.msg.ranIn': 'Ran in {duration}',
63 'agent.msg.mcpCall': 'MCP call',
64 'agent.msg.mcpCalls': 'Called MCP tools',
65 'agent.msg.failed': 'Failed',
66 'agent.msg.noOutput': '(no output)',
67 'agent.msg.fileWrite': 'Write file',
68 'agent.msg.fileEdit': 'Edit file',
69 'agent.msg.fileEdit.replacements': '{count} replacements',
70 'agent.msg.fileRead': 'Read file',
71 'agent.msg.fileRead.range': 'from line {offset} · {lines} lines',
72 'agent.msg.dirList': 'List directory',
73 'agent.msg.dirList.entries': '{count} entries',
74 'agent.msg.json': 'Structured output',
75 'agent.msg.output': 'Output',
76 'agent.msg.artifact.download': 'Download',
77 'agent.msg.deliverables': 'Deliverables',
78 'agent.msg.fileKind.archive': 'Archive',
79 'agent.msg.fileKind.markdown': 'Markdown',
80 'agent.msg.fileKind.table': 'Table',
81 'agent.msg.fileKind.text': 'Text',
82 'agent.msg.fileKind.file': 'File',
83 'agent.msg.truncated': 'Content truncated',
84 'agent.msg.emptyFile': '(empty file)',
85 'agent.msg.emptyDir': '(empty directory)',
86 'agent.msg.context': 'Context {percent} used',
87 'agent.approval.title': 'Approval required',
88 'agent.approval.approve': 'Approve',
89 'agent.approval.reject': 'Reject',
90 'agent.question.title': 'The agent needs your answer',
91 'agent.question.submit': 'Submit answer',
92 'agent.form.title': 'New agent',
93 'agent.form.description': 'Creating an agent opens a long-lived runtime session; instructions are fixed afterwards and the provider/model come from the platform default.',
94 'agent.form.name': 'Name',
95 'agent.form.summary': 'Description · optional',
96 'agent.form.instructions': 'System instructions · optional',
97 'agent.form.instructionsHint': 'Persona and working style — fixed for this session.',
98 'agent.form.nameRequired': 'Name is required',
99} as const
No newline at end of file
Modified web/src/shared/i18n/messages/en/index.ts web/src/shared/i18n/messages/en/index.ts +2 −0 View web/src/shared/i18n/messages/en/index.ts
@@ -1,4 +1,5 @@
11import type { MessageKey } from '../zh-CN'
2import { agent } from './agent'
23import { auth } from './auth'
34import { chat } from './chat'
45import { chatComposer } from './chatComposer'
@@ -36,6 +37,7 @@
3637 ...repo,
3738 ...issues,
3839 ...issueEvents,
40 ...agent,
3941 ...space,
4042 ...misc,
4143 ...nav,
Added web/src/shared/i18n/messages/zh-CN/agent.ts web/src/shared/i18n/messages/zh-CN/agent.ts +99 −0 View web/src/shared/i18n/messages/zh-CN/agent.ts
@@ -0,0 +1,99 @@
1/** Agent sets and their sandbox-backed conversations. */
2export const agent = {
3 'agent.titleFallback': '智能体集',
4 'agent.subtitle': '{count} 个智能体',
5 'agent.create': '新建智能体',
6 'agent.empty': '这个智能体集还没有智能体,点击右上角新建。',
7 'agent.selectHint': '在左侧选择一个智能体开始对话,或先新建一个。',
8 'agent.delete': '删除智能体',
9 'agent.chat.empty': '向「{name}」描述你的任务,它会在沙箱里执行并回报结果。',
10 'agent.chat.placeholder': '向 {name} 发送消息',
11 'agent.chat.send': '发送',
12 'agent.chat.scrollBottom': '回到最下面',
13 'agent.chat.stop': '停止',
14 'agent.chat.running': '运行中({status})…',
15 'agent.chat.inline': '内联模式',
16 'agent.chat.mcp.hint': '允许智能体通过 MCP 使用本空间的仓库、工单与合并请求工具。',
17 'agent.chat.mcp.forbidden': '需要空间管理权限,且服务器已配置 MCP 服务。',
18 'agent.chat.repo.none': '未绑定仓库',
19 'agent.chat.repo.hint': '为对话绑定仓库:每次运行都会把该仓库写入系统上下文。',
20 'agent.chat.repo.search': '搜索仓库',
21 'agent.builder.title': '智能体构建器',
22 'agent.builder.online': '运行时在线',
23 'agent.builder.offline': '运行时离线',
24 'agent.builder.heroTitle': '这个智能体需要做什么?',
25 'agent.builder.heroHint': '描述这个智能体要做什么。',
26 'agent.builder.placeholder': '给 智能体构建器 发消息…',
27 'agent.builder.thinking': '正在生成…',
28 'agent.builder.chip.review': '审查前端 Pull Request',
29 'agent.builder.chip.research': '研究竞品并整理结论',
30 'agent.builder.chip.plan': '帮助团队规划和撰写项目',
31 'agent.builder.configTitle': '智能体配置',
32 'agent.builder.configHint': '可直接修改草稿。',
33 'agent.builder.identity': '身份',
34 'agent.builder.avatar': '更换头像',
35 'agent.builder.name': '名称',
36 'agent.builder.namePlaceholder': '例如:深度研究智能体',
37 'agent.builder.description': '描述',
38 'agent.builder.descriptionPlaceholder': '这个智能体做什么?',
39 'agent.builder.behavior': '行为与能力',
40 'agent.builder.instructions': '指令',
41 'agent.builder.instructionsPlaceholder': '写下这个智能体该做什么、关注什么、要避免什么…',
42 'agent.builder.starters': '对话开场建议',
43 'agent.builder.startersHint': '最多三条。点击后填入输入框,不会发送。',
44 'agent.builder.starterPlaceholder': '一条开场建议',
45 'agent.builder.startersAdd': '添加一条',
46 'agent.builder.discard': '放弃创建',
47 'agent.builder.create': '创建并打开',
48 'agent.builder.nameRequired': '请先填写智能体名称',
49 'agent.chat.streaming': '实时输出',
50 'agent.chat.thinking': '思考中',
51 'agent.chat.session': '会话 {id}',
52 'agent.chat.noSession': '该智能体尚未绑定运行时会话。',
53 'agent.msg.copy': '复制',
54 'agent.msg.copied': '已复制',
55 'agent.msg.reasoning': '思考过程',
56 'agent.msg.working': '正在工作',
57 'agent.msg.compaction': '上下文已压缩',
58 'agent.msg.terminal': '终端输出',
59 'agent.msg.terminal.exit': '退出码 {code}',
60 'agent.msg.exec': '已运行命令',
61 'agent.msg.ranCommands': '运行了命令',
62 'agent.msg.ranIn': '已在 {duration} 内运行',
63 'agent.msg.mcpCall': '调用 MCP',
64 'agent.msg.mcpCalls': '调用 MCP 工具',
65 'agent.msg.failed': '执行失败',
66 'agent.msg.noOutput': '(无输出)',
67 'agent.msg.fileWrite': '写入文件',
68 'agent.msg.fileEdit': '编辑文件',
69 'agent.msg.fileEdit.replacements': '{count} 处替换',
70 'agent.msg.fileRead': '读取文件',
71 'agent.msg.fileRead.range': '第 {offset} 行起 · 共 {lines} 行',
72 'agent.msg.dirList': '列出目录',
73 'agent.msg.dirList.entries': '{count} 项',
74 'agent.msg.json': '结构化输出',
75 'agent.msg.output': '输出',
76 'agent.msg.artifact.download': '下载',
77 'agent.msg.deliverables': '交付物',
78 'agent.msg.fileKind.archive': '归档',
79 'agent.msg.fileKind.markdown': 'Markdown',
80 'agent.msg.fileKind.table': '表格',
81 'agent.msg.fileKind.text': '文本',
82 'agent.msg.fileKind.file': '文件',
83 'agent.msg.truncated': '内容已截断',
84 'agent.msg.emptyFile': '(空文件)',
85 'agent.msg.emptyDir': '(空目录)',
86 'agent.msg.context': '上下文占用 {percent}',
87 'agent.approval.title': '等待审批',
88 'agent.approval.approve': '批准',
89 'agent.approval.reject': '拒绝',
90 'agent.question.title': '智能体需要你的确认',
91 'agent.question.submit': '提交回答',
92 'agent.form.title': '新建智能体',
93 'agent.form.description': '创建后将立即在运行时建立长期会话;instructions 之后不可修改,服务商与模型由平台默认提供。',
94 'agent.form.name': '名称',
95 'agent.form.summary': '描述 · 可选',
96 'agent.form.instructions': '系统指令 · 可选',
97 'agent.form.instructionsHint': '智能体的人设与工作方式,创建后固定。',
98 'agent.form.nameRequired': '请填写名称',
99} as const
No newline at end of file
Modified web/src/shared/i18n/messages/zh-CN/index.ts web/src/shared/i18n/messages/zh-CN/index.ts +2 −0 View web/src/shared/i18n/messages/zh-CN/index.ts
@@ -1,4 +1,5 @@
11import { auth } from './auth'
2import { agent } from './agent'
23import { chat } from './chat'
34import { chatComposer } from './chatComposer'
45import { common } from './common'
@@ -36,6 +37,7 @@
3637 ...repo,
3738 ...issues,
3839 ...issueEvents,
40 ...agent,
3941 ...space,
4042 ...misc,
4143 ...nav,
Modified web/vite.config.ts web/vite.config.ts +6 −0 View web/vite.config.ts
@@ -42,6 +42,12 @@
4242 changeOrigin: true,
4343 ws: true,
4444 },
45 // sandbox-agent fanout (SSE): same-origin path so EventSource needs no CORS.
46 '/sandbox': {
47 target: 'http://localhost:8090',
48 changeOrigin: true,
49 rewrite: path => path.replace(/^\/sandbox/, ''),
50 },
4551 },
4652 },
4753})
Modified Cargo.lock Cargo.lock +61 −0 View Cargo.lock
@@ -181,6 +181,30 @@
181181]
182182
183183[[package]]
184name = "agents"
185version = "1.0.0"
186dependencies = [
187 "agent",
188 "async-trait",
189 "axum",
190 "config",
191 "export",
192 "git",
193 "mcp-api",
194 "models",
195 "sandbox-rpc",
196 "sea-orm",
197 "serde",
198 "serde_json",
199 "session-layer",
200 "space",
201 "time 0.3.55",
202 "tracing",
203 "utoipa",
204 "uuid",
205]
206
207[[package]]
184208name = "ahash"
185209version = "0.7.8"
186210source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1491,6 +1515,7 @@
14911515 "serde",
14921516 "thiserror 2.0.20",
14931517 "tracing",
1518 "url 2.5.8",
14941519]
14951520
14961521[[package]]
@@ -2437,6 +2462,7 @@
24372462 "sea-orm",
24382463 "serde",
24392464 "serde_json",
2465 "sha2 0.11.0",
24402466 "storage",
24412467 "thiserror 2.0.20",
24422468 "time 0.3.55",
@@ -4942,6 +4968,7 @@
49424968 "config",
49434969 "email",
49444970 "export",
4971 "http 0.2.12",
49454972 "i18n",
49464973 "models",
49474974 "sea-orm",
@@ -5624,6 +5651,7 @@
56245651 "issues",
56255652 "models",
56265653 "pulls",
5654 "rand 0.8.7",
56275655 "rmcp",
56285656 "schemars",
56295657 "sea-orm",
@@ -5631,6 +5659,7 @@
56315659 "serde_json",
56325660 "sha2 0.11.0",
56335661 "space",
5662 "thiserror 2.0.20",
56345663 "time 0.3.55",
56355664 "tokio",
56365665 "tracing",
@@ -7280,6 +7309,7 @@
72807309name = "pulls"
72817310version = "1.0.0"
72827311dependencies = [
7312 "agent",
72837313 "axum",
72847314 "ci",
72857315 "ci-protocol",
@@ -8277,6 +8307,19 @@
82778307]
82788308
82798309[[package]]
8310name = "sandbox-rpc"
8311version = "1.0.0"
8312dependencies = [
8313 "config",
8314 "prost 0.14.4",
8315 "thiserror 2.0.20",
8316 "tonic",
8317 "tonic-prost",
8318 "tonic-prost-build",
8319 "tracing",
8320]
8321
8322[[package]]
82808323name = "schannel"
82818324version = "0.1.29"
82828325source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8507,6 +8550,19 @@
85078550]
85088551
85098552[[package]]
8553name = "searchhost"
8554version = "1.0.0"
8555dependencies = [
8556 "anyhow",
8557 "config",
8558 "export",
8559 "search",
8560 "tokio",
8561 "tokio-util",
8562 "tracing",
8563]
8564
8565[[package]]
85108566name = "sec1"
85118567version = "0.7.3"
85128568source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -10657,6 +10713,7 @@
1065710713name = "webapi"
1065810714version = "1.0.0"
1065910715dependencies = [
10716 "agents",
1066010717 "auth",
1066110718 "axum",
1066210719 "axum-otel-metrics",
@@ -10667,15 +10724,19 @@
1066710724 "git",
1066810725 "import",
1066910726 "issues",
10727 "models",
1067010728 "opentelemetry",
1067110729 "opentelemetry-otlp",
1067210730 "opentelemetry_sdk",
1067310731 "pulls",
10732 "sea-orm",
1067410733 "search",
1067510734 "serde",
10735 "serde_json",
1067610736 "session",
1067710737 "session-layer",
1067810738 "space",
10739 "time 0.3.55",
1067910740 "tower-http 0.7.0",
1068010741 "tracing",
1068110742 "user",
Modified Cargo.toml Cargo.toml +2 −0 View Cargo.toml
@@ -17,6 +17,7 @@
1717admin = { path = "lib/admin" }
1818admin-rpc = { path = "lib/admin-rpc" }
1919agent = { path = "lib/agent" }
20agents = { path = "lib/agents" }
2021aihost = { path = "app/aihost" }
2122auth = { path = "lib/auth" }
2223cache = { path = "lib/cache" }
@@ -40,6 +41,7 @@
4041import = { path = "lib/import" }
4142webapi = { path = "lib/webapi" }
4243mcp-api = { path = "lib/mcp-api" }
44sandbox-rpc = { path = "lib/sandbox-rpc" }
4345pulls = { path = "lib/pulls" }
4446search = { path = "lib/search" }
4547social = { path = "lib/social" }
Modified openapi.json openapi.json View openapi.json
Diff too large to display.