Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion docs/gateway-rpc-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ type BindStreamParams struct {

```go
type RunInputMedia struct {
URI string `json:"uri"`
URI string `json:"uri,omitempty"`
AssetID string `json:"asset_id,omitempty"`
MimeType string `json:"mime_type"`
FileName string `json:"file_name,omitempty"`
}
Expand All @@ -175,6 +176,12 @@ type RunParams struct {
}
```

- 多模态图片约束:
- `type=image` 时 `media.mime_type` 必填。
- `media.uri` 与 `media.asset_id` 必须二选一,不能同时为空或同时提供。
- `media.uri` 仅用于后端可读取的本地路径;Web 浏览器上传图片应先通过 `POST /api/session-assets` 保存,再在 `gateway.run` 中使用 `media.asset_id` 引用。
- `asset_id` 必须属于当前 `session_id`,不存在或跨 session 引用会在 runtime 输入准备阶段失败。

- Response Schema:
- Success(受理即返回):

Expand Down Expand Up @@ -223,6 +230,49 @@ type RunParams struct {

---

## HTTP API: session assets

浏览器图片上传不应把本地伪路径传给 Runtime。Web 客户端需要在发送前先创建或确认 `session_id`,再通过受鉴权保护的 HTTP API 保存图片,最后在 `gateway.run.input_parts[].media.asset_id` 中引用。

### POST /api/session-assets

- Auth Required: Yes(`Authorization: Bearer <token>`)
- Headers:
- `X-NeoCode-Workspace-Hash`: 当前工作区哈希。多工作区 Web 客户端必须发送;单工作区或旧客户端可省略并回落到默认工作区。
- Content-Type: `multipart/form-data`
- Fields:
- `session_id`: 目标会话 ID,必填。
- `file`: 图片文件,必填。
- Server-side validation:
- 仅接受 `image/png`、`image/jpeg`、`image/webp`。
- MIME 以服务端文件头检测结果为准,不信任浏览器声明。
- 空文件返回 `400`。
- 超过 `MaxSessionAssetBytes` 返回 `413`。
- 非图片或不支持类型返回 `415`。
- 未认证返回 `401`,Origin/CORS 或 ACL 拒绝返回 `403`。
- 工作区不存在返回 `404 workspace not found`;目标 session 不在该工作区返回 `404 session not found`。
- Response:

```json
{
"session_id": "sess-1",
"asset_id": "asset-1",
"mime_type": "image/png",
"size": 1024
}
```

### GET /api/session-assets/{session_id}/{asset_id}

- Auth Required: Yes(`Authorization: Bearer <token>`)
- Headers:
- `X-NeoCode-Workspace-Hash`: 当前工作区哈希。多工作区 Web 客户端必须发送;省略时回落到默认工作区。
- 返回图片二进制,`Content-Type` 为保存时确认的 MIME。
- 用于历史消息缩略图按需读取。
- 工作区不存在返回 `404 workspace not found`;不存在或不可见的 asset 返回 `404 asset not found`。

---

## Method: gateway.compact

- Stability: Stable
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/gateway-error-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
| --- | --- | --- | --- | --- | --- |
| `invalid_frame` | `200` | `-32700` / `-32600` / `-32602` | 请求帧结构或编码不合法。包括 JSON 解析失败、请求体包含多余 JSON 值、`id/jsonrpc` 非法、`params` 严格解码失败。 | 非法 JSON;`id` 为 `null`;`params` 含未知字段。 | 不要直接重试,先修复请求构造器。 |
| `invalid_action` | `200` | `-32602` | 动作参数值非法,但方法本身存在。 | `params.channel` 不在 `all/ipc/ws/sse`;`params.decision` 非 `allow_once/allow_session/reject`。 | 视为调用方输入错误,修正参数后再发。 |
| `invalid_multimodal_payload` | `200` | `-32602` | `gateway.run` 的 `input_parts` 结构或字段不满足契约。 | `image` 分片缺少 `media.uri` `media.mime_type`;`text` 分片文本为空。 | 校验输入分片后重试,不做盲重试。 |
| `invalid_multimodal_payload` | `200` | `-32602` | `gateway.run` 的 `input_parts` 结构或字段不满足契约。 | `image` 分片缺少 `media.mime_type`,或 `media.uri` / `media.asset_id` 未满足二选一;`text` 分片文本为空。 | 校验输入分片后重试,不做盲重试。 |
| `missing_required_field` | `200` | `-32600` / `-32602` | 缺失必填字段。请求层字段缺失多映射为 `-32600`,方法参数层字段缺失多映射为 `-32602`。 | 缺失 `id`;缺失 `params`;`cancel` 缺失 `run_id`。 | 调整参数补齐必填项再重试。 |
| `unsupported_action` | `200` | `-32601` | 方法未注册或不被网关识别。 | 调用不存在的方法名。 | 客户端按能力探测降级,或升级服务端版本。 |
| `internal_error` | `200` | `-32603` | 网关内部异常或未分类下游异常。 | 结果编码失败;runtime port 不可用;未知运行时错误。 | 采用指数退避重试;持续失败时告警。 |
Expand Down
40 changes: 39 additions & 1 deletion docs/reference/gateway-rpc-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,13 @@ type RunParams struct {
Mode string `json:"mode,omitempty"` // Agent 工作模式:build|plan,可选,默认沿用 session 当前 mode
}

type RunInputMedia struct {
URI string `json:"uri,omitempty"`
AssetID string `json:"asset_id,omitempty"`
MimeType string `json:"mime_type"`
FileName string `json:"file_name,omitempty"`
}

type RunInputPart struct {
Type string `json:"type"` // text|image
Text string `json:"text,omitempty"` // text MUST
Expand All @@ -318,7 +325,7 @@ type RunInputPart struct {
1. `input_text` 与 `input_parts` 至少一项非空。
2. `input_parts` 中:
1. `type=text` 时 `text` `MUST` 非空。
2. `type=image` 时 `media.uri` 与 `media.mime_type` `MUST` 非空。
2. `type=image` 时 `media.mime_type` `MUST` 非空,`media.uri` 与 `media.asset_id` `MUST` 二选一且不能同时提供。Web 上传图片应先调用 `POST /api/session-assets`,再在 `gateway.run` 中用 `asset_id` 引用。
3. 未知字段会因严格解码触发 `invalid_frame`。
4. `run_id` 归一化顺序为:显式 `run_id` > `request_id` > 网关生成 `run_<timestamp>`。
5. `mode` 可选值为 `"build"` 或 `"plan"`,为空时默认沿用 session 当前 mode(新会话默认为 `"build"`)。切换 mode 后,后端会更新 session 并影响后续运行的工具可用性和 prompt 策略。
Expand Down Expand Up @@ -397,6 +404,37 @@ sequenceDiagram
G-->>C: ack(cancel)
```

### HTTP session asset API

浏览器图片上传使用 HTTP API,不通过 JSON-RPC 传输文件内容。客户端发送图片前需要先拥有有效 `session_id`(新会话可先调用 `gateway.createSession`)。

`POST /api/session-assets`

- Auth Required: `Yes`,使用 `Authorization: Bearer <token>`。
- Headers: `X-NeoCode-Workspace-Hash` 携带当前工作区哈希;多工作区 Web 客户端必须发送,省略时回落到默认工作区。
- Content-Type: `multipart/form-data`。
- 字段:`session_id`(必填)、`file`(必填)。
- 仅接受 PNG/JPEG/WebP;服务端按文件头检测 MIME,不信任浏览器声明。
- 空文件返回 `400`,超出 `MaxSessionAssetBytes` 返回 `413`,不支持 MIME 返回 `415`,未认证返回 `401`,Origin/CORS 或 ACL 拒绝返回 `403`。
- 工作区不存在返回 `404 workspace not found`;目标 session 不在该工作区返回 `404 session not found`。
- 成功返回:

```json
{
"session_id": "session-1",
"asset_id": "asset-1",
"mime_type": "image/png",
"size": 1024
}
```

`GET /api/session-assets/{session_id}/{asset_id}`

- Auth Required: `Yes`。
- Headers: `X-NeoCode-Workspace-Hash` 携带当前工作区哈希;多工作区 Web 客户端必须发送。
- 返回图片二进制,用于历史消息缩略图。
- 工作区不存在返回 `404 workspace not found`;不存在、跨 session 或不可见的 asset 返回 `404 asset not found`。

Observation:

1. `gateway_requests_total{method="gateway.run",status="ok|error"}`。
Expand Down
111 changes: 110 additions & 1 deletion internal/cli/gateway_runtime_bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,66 @@ func (b *gatewayRuntimePortBridge) CreateSession(ctx context.Context, input gate
return strings.TrimSpace(session.ID), nil
}

// SaveSessionAsset 将浏览器上传的附件保存到当前工作区的 session asset store。
func (b *gatewayRuntimePortBridge) SaveSessionAsset(
ctx context.Context,
input gateway.SaveSessionAssetInput,
) (gateway.SessionAssetMeta, error) {
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
return gateway.SessionAssetMeta{}, err
}
sessionID := strings.TrimSpace(input.SessionID)
if sessionID == "" {
return gateway.SessionAssetMeta{}, gateway.ErrRuntimeResourceNotFound
}
assetStore, ok := b.sessionStore.(agentsession.AssetStore)
if !ok || assetStore == nil {
return gateway.SessionAssetMeta{}, fmt.Errorf("gateway runtime bridge: session asset store is unavailable")
}
meta, err := assetStore.SaveAsset(ctx, sessionID, input.Reader, strings.TrimSpace(input.MimeType))
if err != nil {
return gateway.SessionAssetMeta{}, err
}
return gateway.SessionAssetMeta{
SessionID: sessionID,
AssetID: strings.TrimSpace(meta.ID),
MimeType: strings.TrimSpace(meta.MimeType),
Size: meta.Size,
}, nil
}

// OpenSessionAsset 打开当前工作区的会话附件,供 Gateway HTTP 读取端点流式返回。
func (b *gatewayRuntimePortBridge) OpenSessionAsset(
ctx context.Context,
input gateway.OpenSessionAssetInput,
) (gateway.OpenSessionAssetResult, error) {
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
return gateway.OpenSessionAssetResult{}, err
}
sessionID := strings.TrimSpace(input.SessionID)
assetID := strings.TrimSpace(input.AssetID)
if sessionID == "" || assetID == "" {
return gateway.OpenSessionAssetResult{}, gateway.ErrRuntimeResourceNotFound
}
assetStore, ok := b.sessionStore.(agentsession.AssetStore)
if !ok || assetStore == nil {
return gateway.OpenSessionAssetResult{}, fmt.Errorf("gateway runtime bridge: session asset store is unavailable")
}
reader, meta, err := assetStore.Open(ctx, sessionID, assetID)
if err != nil {
return gateway.OpenSessionAssetResult{}, err
}
return gateway.OpenSessionAssetResult{
Reader: reader,
Meta: gateway.SessionAssetMeta{
SessionID: sessionID,
AssetID: strings.TrimSpace(meta.ID),
MimeType: strings.TrimSpace(meta.MimeType),
Size: meta.Size,
},
}, nil
}

// DeleteSession 删除/归档指定会话。
func (b *gatewayRuntimePortBridge) DeleteSession(ctx context.Context, input gateway.DeleteSessionInput) (bool, error) {
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
Expand Down Expand Up @@ -1684,11 +1744,13 @@ func convertGatewayRunInput(input gateway.RunInput) agentruntime.PrepareInput {
continue
}
path := strings.TrimSpace(part.Media.URI)
if path == "" {
assetID := strings.TrimSpace(part.Media.AssetID)
if path == "" && assetID == "" {
continue
}
images = append(images, agentruntime.UserImageInput{
Path: path,
AssetID: assetID,
MimeType: strings.TrimSpace(part.Media.MimeType),
})
}
Expand Down Expand Up @@ -1867,6 +1929,7 @@ func convertSessionMessages(messages []providertypes.Message) []gateway.SessionM
convertedMessage := gateway.SessionMessage{
Role: strings.TrimSpace(message.Role),
Content: renderSessionMessageContent(message.Parts),
Parts: convertProviderContentParts(message.Parts),
ToolCallID: strings.TrimSpace(message.ToolCallID),
IsError: message.IsError,
}
Expand All @@ -1885,6 +1948,52 @@ func convertSessionMessages(messages []providertypes.Message) []gateway.SessionM
return converted
}

// convertProviderContentParts 将 provider 通用内容分片转换为 Gateway 会话快照分片。
func convertProviderContentParts(parts []providertypes.ContentPart) []gateway.InputPart {
if len(parts) == 0 {
return nil
}
converted := make([]gateway.InputPart, 0, len(parts))
for _, part := range parts {
switch part.Kind {
case providertypes.ContentPartText:
if text := strings.TrimSpace(part.Text); text != "" {
converted = append(converted, gateway.InputPart{
Type: gateway.InputPartTypeText,
Text: text,
})
}
case providertypes.ContentPartImage:
if part.Image == nil {
continue
}
switch part.Image.SourceType {
case providertypes.ImageSourceSessionAsset:
if part.Image.Asset == nil || strings.TrimSpace(part.Image.Asset.ID) == "" {
continue
}
converted = append(converted, gateway.InputPart{
Type: gateway.InputPartTypeImage,
Media: &gateway.Media{
AssetID: strings.TrimSpace(part.Image.Asset.ID),
MimeType: strings.TrimSpace(part.Image.Asset.MimeType),
},
})
case providertypes.ImageSourceRemote:
if url := strings.TrimSpace(part.Image.URL); url != "" {
converted = append(converted, gateway.InputPart{
Type: gateway.InputPartTypeImage,
Media: &gateway.Media{
URI: url,
},
})
}
}
}
}
return converted
}

// convertRuntimePlanTodoItem 将 session 计划中的 legacy todo 项映射为 gateway 展示结构。
func convertRuntimePlanTodoItem(item agentsession.TodoItem) gateway.PlanTodoItem {
required := false
Expand Down
Loading
Loading