Skip to content

Commit 4f1a74d

Browse files
authored
Merge pull request #705 from Yumiue 支持 Web 多模态图片上传并修正图片 token 预算估算
支持 Web 多模态图片上传并修正图片 token 预算估算
2 parents 2a8f3d1 + 86f5a60 commit 4f1a74d

62 files changed

Lines changed: 2902 additions & 67 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/gateway-rpc-api.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ type BindStreamParams struct {
155155

156156
```go
157157
type RunInputMedia struct {
158-
URI string `json:"uri"`
158+
URI string `json:"uri,omitempty"`
159+
AssetID string `json:"asset_id,omitempty"`
159160
MimeType string `json:"mime_type"`
160161
FileName string `json:"file_name,omitempty"`
161162
}
@@ -175,6 +176,12 @@ type RunParams struct {
175176
}
176177
```
177178

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

@@ -223,6 +230,49 @@ type RunParams struct {
223230

224231
---
225232

233+
## HTTP API: session assets
234+
235+
浏览器图片上传不应把本地伪路径传给 Runtime。Web 客户端需要在发送前先创建或确认 `session_id`,再通过受鉴权保护的 HTTP API 保存图片,最后在 `gateway.run.input_parts[].media.asset_id` 中引用。
236+
237+
### POST /api/session-assets
238+
239+
- Auth Required: Yes(`Authorization: Bearer <token>`
240+
- Headers:
241+
- `X-NeoCode-Workspace-Hash`: 当前工作区哈希。多工作区 Web 客户端必须发送;单工作区或旧客户端可省略并回落到默认工作区。
242+
- Content-Type: `multipart/form-data`
243+
- Fields:
244+
- `session_id`: 目标会话 ID,必填。
245+
- `file`: 图片文件,必填。
246+
- Server-side validation:
247+
- 仅接受 `image/png``image/jpeg``image/webp`
248+
- MIME 以服务端文件头检测结果为准,不信任浏览器声明。
249+
- 空文件返回 `400`
250+
- 超过 `MaxSessionAssetBytes` 返回 `413`
251+
- 非图片或不支持类型返回 `415`
252+
- 未认证返回 `401`,Origin/CORS 或 ACL 拒绝返回 `403`
253+
- 工作区不存在返回 `404 workspace not found`;目标 session 不在该工作区返回 `404 session not found`
254+
- Response:
255+
256+
```json
257+
{
258+
"session_id": "sess-1",
259+
"asset_id": "asset-1",
260+
"mime_type": "image/png",
261+
"size": 1024
262+
}
263+
```
264+
265+
### GET /api/session-assets/{session_id}/{asset_id}
266+
267+
- Auth Required: Yes(`Authorization: Bearer <token>`
268+
- Headers:
269+
- `X-NeoCode-Workspace-Hash`: 当前工作区哈希。多工作区 Web 客户端必须发送;省略时回落到默认工作区。
270+
- 返回图片二进制,`Content-Type` 为保存时确认的 MIME。
271+
- 用于历史消息缩略图按需读取。
272+
- 工作区不存在返回 `404 workspace not found`;不存在或不可见的 asset 返回 `404 asset not found`
273+
274+
---
275+
226276
## Method: gateway.compact
227277

228278
- Stability: Stable

docs/reference/gateway-error-catalog.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
| --- | --- | --- | --- | --- | --- |
1111
| `invalid_frame` | `200` | `-32700` / `-32600` / `-32602` | 请求帧结构或编码不合法。包括 JSON 解析失败、请求体包含多余 JSON 值、`id/jsonrpc` 非法、`params` 严格解码失败。 | 非法 JSON;`id``null``params` 含未知字段。 | 不要直接重试,先修复请求构造器。 |
1212
| `invalid_action` | `200` | `-32602` | 动作参数值非法,但方法本身存在。 | `params.channel` 不在 `all/ipc/ws/sse``params.decision``allow_once/allow_session/reject`| 视为调用方输入错误,修正参数后再发。 |
13-
| `invalid_multimodal_payload` | `200` | `-32602` | `gateway.run``input_parts` 结构或字段不满足契约。 | `image` 分片缺少 `media.uri` `media.mime_type``text` 分片文本为空。 | 校验输入分片后重试,不做盲重试。 |
13+
| `invalid_multimodal_payload` | `200` | `-32602` | `gateway.run``input_parts` 结构或字段不满足契约。 | `image` 分片缺少 `media.mime_type`,或 `media.uri` / `media.asset_id` 未满足二选一`text` 分片文本为空。 | 校验输入分片后重试,不做盲重试。 |
1414
| `missing_required_field` | `200` | `-32600` / `-32602` | 缺失必填字段。请求层字段缺失多映射为 `-32600`,方法参数层字段缺失多映射为 `-32602`| 缺失 `id`;缺失 `params``cancel` 缺失 `run_id`| 调整参数补齐必填项再重试。 |
1515
| `unsupported_action` | `200` | `-32601` | 方法未注册或不被网关识别。 | 调用不存在的方法名。 | 客户端按能力探测降级,或升级服务端版本。 |
1616
| `internal_error` | `200` | `-32603` | 网关内部异常或未分类下游异常。 | 结果编码失败;runtime port 不可用;未知运行时错误。 | 采用指数退避重试;持续失败时告警。 |

docs/reference/gateway-rpc-api.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,13 @@ type RunParams struct {
306306
Mode string `json:"mode,omitempty"` // Agent 工作模式:build|plan,可选,默认沿用 session 当前 mode
307307
}
308308

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

407+
### HTTP session asset API
408+
409+
浏览器图片上传使用 HTTP API,不通过 JSON-RPC 传输文件内容。客户端发送图片前需要先拥有有效 `session_id`(新会话可先调用 `gateway.createSession`)。
410+
411+
`POST /api/session-assets`
412+
413+
- Auth Required: `Yes`,使用 `Authorization: Bearer <token>`
414+
- Headers: `X-NeoCode-Workspace-Hash` 携带当前工作区哈希;多工作区 Web 客户端必须发送,省略时回落到默认工作区。
415+
- Content-Type: `multipart/form-data`
416+
- 字段:`session_id`(必填)、`file`(必填)。
417+
- 仅接受 PNG/JPEG/WebP;服务端按文件头检测 MIME,不信任浏览器声明。
418+
- 空文件返回 `400`,超出 `MaxSessionAssetBytes` 返回 `413`,不支持 MIME 返回 `415`,未认证返回 `401`,Origin/CORS 或 ACL 拒绝返回 `403`
419+
- 工作区不存在返回 `404 workspace not found`;目标 session 不在该工作区返回 `404 session not found`
420+
- 成功返回:
421+
422+
```json
423+
{
424+
"session_id": "session-1",
425+
"asset_id": "asset-1",
426+
"mime_type": "image/png",
427+
"size": 1024
428+
}
429+
```
430+
431+
`GET /api/session-assets/{session_id}/{asset_id}`
432+
433+
- Auth Required: `Yes`
434+
- Headers: `X-NeoCode-Workspace-Hash` 携带当前工作区哈希;多工作区 Web 客户端必须发送。
435+
- 返回图片二进制,用于历史消息缩略图。
436+
- 工作区不存在返回 `404 workspace not found`;不存在、跨 session 或不可见的 asset 返回 `404 asset not found`
437+
400438
Observation:
401439

402440
1. `gateway_requests_total{method="gateway.run",status="ok|error"}`

internal/cli/gateway_runtime_bridge.go

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,66 @@ func (b *gatewayRuntimePortBridge) CreateSession(ctx context.Context, input gate
697697
return strings.TrimSpace(session.ID), nil
698698
}
699699

700+
// SaveSessionAsset 将浏览器上传的附件保存到当前工作区的 session asset store。
701+
func (b *gatewayRuntimePortBridge) SaveSessionAsset(
702+
ctx context.Context,
703+
input gateway.SaveSessionAssetInput,
704+
) (gateway.SessionAssetMeta, error) {
705+
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
706+
return gateway.SessionAssetMeta{}, err
707+
}
708+
sessionID := strings.TrimSpace(input.SessionID)
709+
if sessionID == "" {
710+
return gateway.SessionAssetMeta{}, gateway.ErrRuntimeResourceNotFound
711+
}
712+
assetStore, ok := b.sessionStore.(agentsession.AssetStore)
713+
if !ok || assetStore == nil {
714+
return gateway.SessionAssetMeta{}, fmt.Errorf("gateway runtime bridge: session asset store is unavailable")
715+
}
716+
meta, err := assetStore.SaveAsset(ctx, sessionID, input.Reader, strings.TrimSpace(input.MimeType))
717+
if err != nil {
718+
return gateway.SessionAssetMeta{}, err
719+
}
720+
return gateway.SessionAssetMeta{
721+
SessionID: sessionID,
722+
AssetID: strings.TrimSpace(meta.ID),
723+
MimeType: strings.TrimSpace(meta.MimeType),
724+
Size: meta.Size,
725+
}, nil
726+
}
727+
728+
// OpenSessionAsset 打开当前工作区的会话附件,供 Gateway HTTP 读取端点流式返回。
729+
func (b *gatewayRuntimePortBridge) OpenSessionAsset(
730+
ctx context.Context,
731+
input gateway.OpenSessionAssetInput,
732+
) (gateway.OpenSessionAssetResult, error) {
733+
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
734+
return gateway.OpenSessionAssetResult{}, err
735+
}
736+
sessionID := strings.TrimSpace(input.SessionID)
737+
assetID := strings.TrimSpace(input.AssetID)
738+
if sessionID == "" || assetID == "" {
739+
return gateway.OpenSessionAssetResult{}, gateway.ErrRuntimeResourceNotFound
740+
}
741+
assetStore, ok := b.sessionStore.(agentsession.AssetStore)
742+
if !ok || assetStore == nil {
743+
return gateway.OpenSessionAssetResult{}, fmt.Errorf("gateway runtime bridge: session asset store is unavailable")
744+
}
745+
reader, meta, err := assetStore.Open(ctx, sessionID, assetID)
746+
if err != nil {
747+
return gateway.OpenSessionAssetResult{}, err
748+
}
749+
return gateway.OpenSessionAssetResult{
750+
Reader: reader,
751+
Meta: gateway.SessionAssetMeta{
752+
SessionID: sessionID,
753+
AssetID: strings.TrimSpace(meta.ID),
754+
MimeType: strings.TrimSpace(meta.MimeType),
755+
Size: meta.Size,
756+
},
757+
}, nil
758+
}
759+
700760
// DeleteSession 删除/归档指定会话。
701761
func (b *gatewayRuntimePortBridge) DeleteSession(ctx context.Context, input gateway.DeleteSessionInput) (bool, error) {
702762
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
@@ -1684,11 +1744,13 @@ func convertGatewayRunInput(input gateway.RunInput) agentruntime.PrepareInput {
16841744
continue
16851745
}
16861746
path := strings.TrimSpace(part.Media.URI)
1687-
if path == "" {
1747+
assetID := strings.TrimSpace(part.Media.AssetID)
1748+
if path == "" && assetID == "" {
16881749
continue
16891750
}
16901751
images = append(images, agentruntime.UserImageInput{
16911752
Path: path,
1753+
AssetID: assetID,
16921754
MimeType: strings.TrimSpace(part.Media.MimeType),
16931755
})
16941756
}
@@ -1867,6 +1929,7 @@ func convertSessionMessages(messages []providertypes.Message) []gateway.SessionM
18671929
convertedMessage := gateway.SessionMessage{
18681930
Role: strings.TrimSpace(message.Role),
18691931
Content: renderSessionMessageContent(message.Parts),
1932+
Parts: convertProviderContentParts(message.Parts),
18701933
ToolCallID: strings.TrimSpace(message.ToolCallID),
18711934
IsError: message.IsError,
18721935
}
@@ -1885,6 +1948,52 @@ func convertSessionMessages(messages []providertypes.Message) []gateway.SessionM
18851948
return converted
18861949
}
18871950

1951+
// convertProviderContentParts 将 provider 通用内容分片转换为 Gateway 会话快照分片。
1952+
func convertProviderContentParts(parts []providertypes.ContentPart) []gateway.InputPart {
1953+
if len(parts) == 0 {
1954+
return nil
1955+
}
1956+
converted := make([]gateway.InputPart, 0, len(parts))
1957+
for _, part := range parts {
1958+
switch part.Kind {
1959+
case providertypes.ContentPartText:
1960+
if text := strings.TrimSpace(part.Text); text != "" {
1961+
converted = append(converted, gateway.InputPart{
1962+
Type: gateway.InputPartTypeText,
1963+
Text: text,
1964+
})
1965+
}
1966+
case providertypes.ContentPartImage:
1967+
if part.Image == nil {
1968+
continue
1969+
}
1970+
switch part.Image.SourceType {
1971+
case providertypes.ImageSourceSessionAsset:
1972+
if part.Image.Asset == nil || strings.TrimSpace(part.Image.Asset.ID) == "" {
1973+
continue
1974+
}
1975+
converted = append(converted, gateway.InputPart{
1976+
Type: gateway.InputPartTypeImage,
1977+
Media: &gateway.Media{
1978+
AssetID: strings.TrimSpace(part.Image.Asset.ID),
1979+
MimeType: strings.TrimSpace(part.Image.Asset.MimeType),
1980+
},
1981+
})
1982+
case providertypes.ImageSourceRemote:
1983+
if url := strings.TrimSpace(part.Image.URL); url != "" {
1984+
converted = append(converted, gateway.InputPart{
1985+
Type: gateway.InputPartTypeImage,
1986+
Media: &gateway.Media{
1987+
URI: url,
1988+
},
1989+
})
1990+
}
1991+
}
1992+
}
1993+
}
1994+
return converted
1995+
}
1996+
18881997
// convertRuntimePlanTodoItem 将 session 计划中的 legacy todo 项映射为 gateway 展示结构。
18891998
func convertRuntimePlanTodoItem(item agentsession.TodoItem) gateway.PlanTodoItem {
18901999
required := false

0 commit comments

Comments
 (0)