From 9633fdae99a465446c5b2f88697e377f1c177060 Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Thu, 20 Aug 2026 20:59:02 +0800
Subject: [PATCH 01/54] feat: add xAI video extension and refine media playback
---
backend/docs/docs.go | 105 ++++++++++++++++-
backend/docs/swagger.json | 105 ++++++++++++++++-
backend/docs/swagger.yaml | 71 ++++++++++++
.../application/channel/model_catalog.go | 63 +++++++---
.../application/channel/model_catalog_test.go | 65 ++++++++++-
.../channel/service_default_route.go | 2 +
.../application/channel/service_model.go | 5 +-
.../channel/service_view_normalization.go | 2 +-
.../conversation/model_option_policy.go | 4 +
.../conversation/service_media_cancel.go | 3 +-
.../conversation/service_media_video.go | 101 +++++++++++++---
.../settings/model_option_policy.go | 1 +
.../internal/application/settings/service.go | 3 +
.../application/settings/service_seed_test.go | 26 +++++
backend/internal/infra/config/config.go | 3 +
backend/internal/infra/llm/adapter.go | 10 +-
backend/internal/infra/llm/adapter_test.go | 9 ++
backend/internal/infra/llm/client.go | 10 +-
.../internal/infra/llm/endpoint_url_test.go | 6 +
backend/internal/infra/llm/openai.go | 2 +
backend/internal/infra/llm/xai_videos.go | 99 +++++++++++++++-
backend/internal/infra/llm/xai_videos_test.go | 39 +++++++
.../transport/http/channel/dto_request.go | 2 +-
.../http/conversation/dto_request.go | 12 ++
.../http/conversation/handler_media.go | 72 +++++++++++-
.../transport/http/conversation/router.go | 1 +
frontend/features/admin/api/llm.types.ts | 13 ++-
.../conversation/admin-conversation.tsx | 40 ++++---
.../models/models-capabilities-presets.tsx | 40 ++++++-
.../sections/models/models-order-sheet.tsx | 2 +-
.../sections/models/models-sheet.tsx | 76 +++++++++---
.../sections/models/models-table.tsx | 2 +-
.../sections/upstreams/upstreams-sheet.tsx | 108 +++++++++---------
.../sections/upstreams/upstreams-table.tsx | 1 +
.../features/admin/model/billing-settings.ts | 6 +-
.../admin/model/conversation-settings.ts | 3 +
frontend/features/admin/types/llm.ts | 1 +
frontend/features/admin/utils/llm-display.ts | 14 ++-
.../chat/components/app-chat-area.tsx | 90 ++++++++++-----
.../chat/components/message/message-bot.tsx | 108 +++++++++++++-----
.../chat/components/message/message-meta.tsx | 28 ++---
.../chat/components/sections/chat-area.tsx | 72 ++++++++----
.../chat/components/sections/chat-input.tsx | 102 ++++++++++-------
.../components/sections/chat-model-config.tsx | 5 +-
.../chat/hooks/use-chat-message-submit.ts | 32 +++++-
.../chat/hooks/use-chat-model-options.ts | 75 ++++++++----
frontend/features/chat/model/chat-task.ts | 29 ++++-
frontend/features/chat/types/chat-runtime.ts | 9 +-
.../i18n/messages/en-US/admin-billing.json | 2 +-
.../i18n/messages/en-US/admin-models.json | 3 +-
.../i18n/messages/en-US/admin-upstreams.json | 3 +-
frontend/i18n/messages/en-US/chat.json | 8 ++
.../i18n/messages/zh-CN/admin-billing.json | 2 +-
.../i18n/messages/zh-CN/admin-models.json | 3 +-
.../i18n/messages/zh-CN/admin-upstreams.json | 3 +-
frontend/i18n/messages/zh-CN/chat.json | 8 ++
frontend/shared/api/conversation.ts | 16 +++
frontend/shared/api/conversation.types.ts | 5 +
.../components/file-preview/preview-media.tsx | 72 +++++++++---
.../markdown/streamdown-components.tsx | 69 ++++-------
.../shared/components/media-action-bar.tsx | 54 +++++++++
frontend/shared/lib/model-option-policy.ts | 4 +
packages/api-contract/src/types.generated.ts | 41 ++++++-
63 files changed, 1576 insertions(+), 394 deletions(-)
create mode 100644 frontend/shared/components/media-action-bar.tsx
diff --git a/backend/docs/docs.go b/backend/docs/docs.go
index b40e91b3e..01625408e 100644
--- a/backend/docs/docs.go
+++ b/backend/docs/docs.go
@@ -10371,6 +10371,64 @@ const docTemplate = `{
}
}
},
+ "/conversations/{id}/media/videos/extensions/stream": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/x-ndjson"
+ ],
+ "tags": [
+ "Conversations"
+ ],
+ "summary": "扩展会话视频",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "会话 Public ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "视频扩展请求",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/MediaVideoExtensionRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "NDJSON stream",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/Envelope"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/Envelope"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/Envelope"
+ }
+ }
+ }
+ }
+ },
"/conversations/{id}/messages": {
"get": {
"security": [
@@ -19225,6 +19283,50 @@ const docTemplate = `{
}
}
},
+ "MediaVideoExtensionRequest": {
+ "type": "object",
+ "required": [
+ "prompt",
+ "sourceVideoFileID"
+ ],
+ "properties": {
+ "branchReason": {
+ "type": "string",
+ "enum": [
+ "default",
+ "retry",
+ "edit"
+ ]
+ },
+ "clientRunID": {
+ "type": "string",
+ "maxLength": 64
+ },
+ "model": {
+ "type": "string",
+ "maxLength": 128
+ },
+ "options": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "parentMessagePublicID": {
+ "type": "string",
+ "maxLength": 32
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "sourceMessagePublicID": {
+ "type": "string",
+ "maxLength": 32
+ },
+ "sourceVideoFileID": {
+ "type": "string",
+ "maxLength": 128
+ }
+ }
+ },
"MemoryErrorDoc": {
"type": "object",
"required": [
@@ -20405,7 +20507,8 @@ const docTemplate = `{
"chat",
"image_generation",
"image_edit",
- "video_generation"
+ "video_generation",
+ "video_extension"
]
}
}
diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json
index a099428df..652b35a74 100644
--- a/backend/docs/swagger.json
+++ b/backend/docs/swagger.json
@@ -10364,6 +10364,64 @@
}
}
},
+ "/conversations/{id}/media/videos/extensions/stream": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/x-ndjson"
+ ],
+ "tags": [
+ "Conversations"
+ ],
+ "summary": "扩展会话视频",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "会话 Public ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "视频扩展请求",
+ "name": "payload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/MediaVideoExtensionRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "NDJSON stream",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/Envelope"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/Envelope"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/Envelope"
+ }
+ }
+ }
+ }
+ },
"/conversations/{id}/messages": {
"get": {
"security": [
@@ -19218,6 +19276,50 @@
}
}
},
+ "MediaVideoExtensionRequest": {
+ "type": "object",
+ "required": [
+ "prompt",
+ "sourceVideoFileID"
+ ],
+ "properties": {
+ "branchReason": {
+ "type": "string",
+ "enum": [
+ "default",
+ "retry",
+ "edit"
+ ]
+ },
+ "clientRunID": {
+ "type": "string",
+ "maxLength": 64
+ },
+ "model": {
+ "type": "string",
+ "maxLength": 128
+ },
+ "options": {
+ "type": "object",
+ "additionalProperties": true
+ },
+ "parentMessagePublicID": {
+ "type": "string",
+ "maxLength": 32
+ },
+ "prompt": {
+ "type": "string"
+ },
+ "sourceMessagePublicID": {
+ "type": "string",
+ "maxLength": 32
+ },
+ "sourceVideoFileID": {
+ "type": "string",
+ "maxLength": 128
+ }
+ }
+ },
"MemoryErrorDoc": {
"type": "object",
"required": [
@@ -20398,7 +20500,8 @@
"chat",
"image_generation",
"image_edit",
- "video_generation"
+ "video_generation",
+ "video_extension"
]
}
}
diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml
index becf9714a..dfd916b58 100644
--- a/backend/docs/swagger.yaml
+++ b/backend/docs/swagger.yaml
@@ -3915,6 +3915,38 @@ definitions:
- data
- errorMsg
type: object
+ MediaVideoExtensionRequest:
+ properties:
+ branchReason:
+ enum:
+ - default
+ - retry
+ - edit
+ type: string
+ clientRunID:
+ maxLength: 64
+ type: string
+ model:
+ maxLength: 128
+ type: string
+ options:
+ additionalProperties: true
+ type: object
+ parentMessagePublicID:
+ maxLength: 32
+ type: string
+ prompt:
+ type: string
+ sourceMessagePublicID:
+ maxLength: 32
+ type: string
+ sourceVideoFileID:
+ maxLength: 128
+ type: string
+ required:
+ - prompt
+ - sourceVideoFileID
+ type: object
MemoryErrorDoc:
properties:
data: {}
@@ -4742,6 +4774,7 @@ definitions:
- image_generation
- image_edit
- video_generation
+ - video_extension
type: string
type: object
ModelProbeResponse:
@@ -15631,6 +15664,44 @@ paths:
summary: 更新会话标签
tags:
- chat
+ /conversations/{id}/media/videos/extensions/stream:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: 会话 Public ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: 视频扩展请求
+ in: body
+ name: payload
+ required: true
+ schema:
+ $ref: '#/definitions/MediaVideoExtensionRequest'
+ produces:
+ - application/x-ndjson
+ responses:
+ "200":
+ description: NDJSON stream
+ schema:
+ type: string
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/Envelope'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/Envelope'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/Envelope'
+ summary: 扩展会话视频
+ tags:
+ - Conversations
/conversations/{id}/messages:
get:
consumes:
diff --git a/backend/internal/application/channel/model_catalog.go b/backend/internal/application/channel/model_catalog.go
index e19bba7a9..93b7e6db7 100644
--- a/backend/internal/application/channel/model_catalog.go
+++ b/backend/internal/application/channel/model_catalog.go
@@ -16,12 +16,15 @@ const (
TaskTypeImageEdit = "image_edit"
// TaskTypeVideoGeneration 表示视频生成任务。
TaskTypeVideoGeneration = "video_generation"
+ // TaskTypeVideoExtension 表示基于源视频的扩展任务。
+ TaskTypeVideoExtension = "video_extension"
- modelKindChat = "chat"
- modelKindAudio = "audio"
- modelKindImageGen = "image_gen"
- modelKindImageEdit = "image_edit"
- modelKindVideoGen = "video_gen"
+ modelKindChat = "chat"
+ modelKindAudio = "audio"
+ modelKindImageGen = "image_gen"
+ modelKindImageEdit = "image_edit"
+ modelKindVideoGen = "video_gen"
+ modelKindVideoExtension = "video_extension"
compatibleOpenAI = "openai"
compatibleAnthropic = "anthropic"
@@ -38,6 +41,7 @@ const (
protocolXAIImage = llm.AdapterXAIImage
protocolXAIImageEdits = llm.AdapterXAIImageEdits
protocolXAIVideo = llm.AdapterXAIVideo
+ protocolXAIVideoExtensions = llm.AdapterXAIVideoExtensions
)
var protocolDefaultKindOrder = []string{
@@ -46,6 +50,7 @@ var protocolDefaultKindOrder = []string{
modelKindImageGen,
modelKindImageEdit,
modelKindVideoGen,
+ modelKindVideoExtension,
}
func normalizeCompatible(raw string) string {
@@ -134,11 +139,12 @@ func systemFallbackProtocols(compatible string) map[string]string {
}
case compatibleXAI:
return map[string]string{
- modelKindChat: llm.AdapterXAIResponses,
- modelKindAudio: llm.AdapterXAIResponses,
- modelKindImageGen: protocolXAIImage,
- modelKindImageEdit: protocolXAIImageEdits,
- modelKindVideoGen: protocolXAIVideo,
+ modelKindChat: llm.AdapterXAIResponses,
+ modelKindAudio: llm.AdapterXAIResponses,
+ modelKindImageGen: protocolXAIImage,
+ modelKindImageEdit: protocolXAIImageEdits,
+ modelKindVideoGen: protocolXAIVideo,
+ modelKindVideoExtension: protocolXAIVideoExtensions,
}
case compatibleOpenRouter:
return map[string]string{
@@ -176,7 +182,8 @@ func isKnownProtocol(raw string) bool {
protocolGeminiInteractions,
protocolXAIImage,
protocolXAIImageEdits,
- protocolXAIVideo:
+ protocolXAIVideo,
+ protocolXAIVideoExtensions:
return true
default:
return false
@@ -210,7 +217,7 @@ func resolveRouteProtocol(explicit string, upCompatible string, defaultsJSON str
return "", ErrProtocolRequired
}
-// resolveRouteProtocols 解析批量导入时的协议列表,图片生成/编辑模型会按协议能力生成一到两条绑定。
+// resolveRouteProtocols 解析批量导入时的协议列表,并为同一媒体模型补齐配套协议绑定。
func resolveRouteProtocols(explicit []string, upCompatible string, defaultsJSON string, kindsJSON string) ([]string, error) {
protocols := make([]string, 0, len(explicit))
seen := make(map[string]struct{}, len(explicit))
@@ -250,6 +257,12 @@ func resolveRouteProtocols(explicit []string, upCompatible string, defaultsJSON
}
}
}
+ if hasModelKind(kinds, modelKindVideoGen) && hasModelKind(kinds, modelKindVideoExtension) && normalizeCompatible(upCompatible) == compatibleXAI {
+ generationProtocol := defaultRouteProtocolForKind(upCompatible, defaultsJSON, modelKindVideoGen)
+ if generationProtocol == protocolXAIVideo {
+ return []string{protocolXAIVideo, protocolXAIVideoExtensions}, nil
+ }
+ }
protocol, err := resolveRouteProtocol("", upCompatible, defaultsJSON, kindsJSON)
if err != nil {
@@ -318,7 +331,7 @@ func isProtocolAllowedForKinds(kindsJSON string, protocol string) bool {
return false
}
-// isSupportedRouteProtocolCombination 限制同一绑定 pair 只能单协议,或图片生成/编辑双协议。
+// isSupportedRouteProtocolCombination 限制同一绑定 pair 只能单协议,或同一媒体模型的配套协议。
func isSupportedRouteProtocolCombination(protocols []string) bool {
seen := make(map[string]struct{}, len(protocols))
for _, raw := range protocols {
@@ -341,7 +354,12 @@ func isSupportedRouteProtocolCombination(protocols []string) bool {
}
_, hasGeneration = seen[protocolXAIImage]
_, hasEdit = seen[protocolXAIImageEdits]
- return hasGeneration && hasEdit
+ if hasGeneration && hasEdit {
+ return true
+ }
+ _, hasVideoGeneration := seen[protocolXAIVideo]
+ _, hasVideoExtension := seen[protocolXAIVideoExtensions]
+ return hasVideoGeneration && hasVideoExtension
}
func protocolDefaultForKind(defaultsJSON string, kind string) string {
@@ -421,6 +439,8 @@ func isProtocolAllowedForKind(kind string, protocol string) bool {
default:
return false
}
+ case modelKindVideoExtension:
+ return protocol == protocolXAIVideoExtensions
default:
return false
}
@@ -436,6 +456,8 @@ func NormalizeTaskType(raw string) string {
return TaskTypeImageEdit
case TaskTypeVideoGeneration:
return TaskTypeVideoGeneration
+ case TaskTypeVideoExtension:
+ return TaskTypeVideoExtension
default:
return TaskTypeChat
}
@@ -453,7 +475,9 @@ func IsRouteAllowedForTask(taskType string, kindsJSON string, protocol string) b
case TaskTypeImageEdit:
return isProtocolAllowedForKind(modelKindImageEdit, protocol)
case TaskTypeVideoGeneration:
- return isProtocolAllowedForKind(modelKindVideoGen, protocol)
+ return isProtocolAllowedForKind(modelKindVideoGen, protocol) && protocol != protocolXAIVideoExtensions
+ case TaskTypeVideoExtension:
+ return isProtocolAllowedForKind(modelKindVideoExtension, protocol)
default:
return isProtocolAllowedForKind(modelKindChat, protocol) || isProtocolAllowedForKind(modelKindAudio, protocol)
}
@@ -464,7 +488,9 @@ func IsRouteAllowedForTask(taskType string, kindsJSON string, protocol string) b
case TaskTypeImageEdit:
return hasModelKind(kinds, modelKindImageEdit) && isProtocolAllowedForKind(modelKindImageEdit, protocol)
case TaskTypeVideoGeneration:
- return hasModelKind(kinds, modelKindVideoGen) && isProtocolAllowedForKind(modelKindVideoGen, protocol)
+ return hasModelKind(kinds, modelKindVideoGen) && isProtocolAllowedForKind(modelKindVideoGen, protocol) && protocol != protocolXAIVideoExtensions
+ case TaskTypeVideoExtension:
+ return hasModelKind(kinds, modelKindVideoExtension) && isProtocolAllowedForKind(modelKindVideoExtension, protocol)
default:
for _, kind := range kinds {
if (kind == modelKindChat || kind == modelKindAudio) && isProtocolAllowedForKind(kind, protocol) {
@@ -507,8 +533,9 @@ func inferKindsJSON(platformModelName string) string {
return `["image_gen","image_edit"]`
case code == "dall-e-3", strings.HasPrefix(code, "imagen-"):
return `["image_gen"]`
- case code == "sora", code == "veo-2", strings.HasPrefix(code, "kling"),
- strings.HasPrefix(code, "veo-"), isXAIVideoGenerationModel(code):
+ case isXAIVideoGenerationModel(code):
+ return `["video_gen","video_extension"]`
+ case code == "sora", code == "veo-2", strings.HasPrefix(code, "kling"), strings.HasPrefix(code, "veo-"):
return `["video_gen"]`
case strings.HasPrefix(code, "gpt-4o-audio"):
return `["audio"]`
diff --git a/backend/internal/application/channel/model_catalog_test.go b/backend/internal/application/channel/model_catalog_test.go
index 183e45b26..b5de164e8 100644
--- a/backend/internal/application/channel/model_catalog_test.go
+++ b/backend/internal/application/channel/model_catalog_test.go
@@ -54,6 +54,9 @@ func TestProtocolDefaultsForXAIUsesXAIResponsesForConversationKinds(t *testing.T
if defaults[modelKindVideoGen] != "xai_video" {
t.Fatalf("expected xAI video default, got %q in %s", defaults[modelKindVideoGen], raw)
}
+ if defaults[modelKindVideoExtension] != "xai_video_extensions" {
+ t.Fatalf("expected xAI video extension default, got %q in %s", defaults[modelKindVideoExtension], raw)
+ }
}
func TestProtocolDefaultsForOpenRouterUsesOpenRouterResponsesForConversationKinds(t *testing.T) {
@@ -445,14 +448,22 @@ func TestInferKindsJSONRecognizesGeminiOmniInteractionsModel(t *testing.T) {
}
}
-func TestInferKindsJSONRecognizesVideoOnlyModels(t *testing.T) {
- for _, modelName := range []string{"veo-3.1-fast", "grok-imagine-video", "grok-imagine-video-1.5-preview"} {
+func TestInferKindsJSONRecognizesVideoGenerationModels(t *testing.T) {
+ for _, modelName := range []string{"veo-3.1-fast"} {
if got := inferKindsJSON(modelName); got != `["video_gen"]` {
t.Fatalf("expected %s to infer video generation kind, got %s", modelName, got)
}
}
}
+func TestInferKindsJSONRecognizesXAIVideoExtensionModels(t *testing.T) {
+ for _, modelName := range []string{"grok-imagine-video", "grok-imagine-video-1.5-preview"} {
+ if got := inferKindsJSON(modelName); got != `["video_gen","video_extension"]` {
+ t.Fatalf("expected %s to infer video generation and extension kinds, got %s", modelName, got)
+ }
+ }
+}
+
func TestInferKindsJSONRecognizesXAIImageModels(t *testing.T) {
for _, modelName := range []string{
"grok-imagine-image",
@@ -515,7 +526,7 @@ func TestResolveRouteProtocolAcceptsExplicitProtocolForAnyDeclaredKind(t *testin
}
}
-func TestSupportedRouteProtocolCombinationOnlyAllowsSameProviderImagePair(t *testing.T) {
+func TestSupportedRouteProtocolCombinationOnlyAllowsCompatibleMediaPairs(t *testing.T) {
tests := []struct {
name string
protocols []string
@@ -525,6 +536,7 @@ func TestSupportedRouteProtocolCombinationOnlyAllowsSameProviderImagePair(t *tes
{name: "single image generation", protocols: []string{"openai_image_generations"}, want: true},
{name: "openai image generation and edit", protocols: []string{"openai_image_generations", "openai_image_edits"}, want: true},
{name: "xai image generation and edit", protocols: []string{"xai_image", "xai_image_edits"}, want: true},
+ {name: "xai video generation and extension", protocols: []string{"xai_video", "xai_video_extensions"}, want: true},
{name: "duplicate protocol", protocols: []string{"openai_responses", "openai_responses"}, want: true},
{name: "two chat protocols", protocols: []string{"openai_responses", "openai_chat_completions"}, want: false},
{name: "image generation with chat", protocols: []string{"openai_image_generations", "openai_responses"}, want: false},
@@ -603,6 +615,32 @@ func TestResolveRouteProtocolsExpandsXAIDualImageKinds(t *testing.T) {
}
}
+func TestResolveRouteProtocolsExpandsXAIVideoProtocols(t *testing.T) {
+ protocols, err := resolveRouteProtocols(nil, compatibleXAI, "", `["video_gen","video_extension"]`)
+ if err != nil {
+ t.Fatalf("resolve xAI video protocols: %v", err)
+ }
+ expected := []string{"xai_video", "xai_video_extensions"}
+ if len(protocols) != len(expected) {
+ t.Fatalf("expected %d protocols, got %#v", len(expected), protocols)
+ }
+ for i, expectedProtocol := range expected {
+ if protocols[i] != expectedProtocol {
+ t.Fatalf("expected protocol %d to be %q, got %#v", i, expectedProtocol, protocols)
+ }
+ }
+}
+
+func TestResolveRouteProtocolsDoesNotAddExtensionWithoutExtensionKind(t *testing.T) {
+ protocols, err := resolveRouteProtocols(nil, compatibleXAI, "", `["video_gen"]`)
+ if err != nil {
+ t.Fatalf("resolve xAI generation-only protocol: %v", err)
+ }
+ if len(protocols) != 1 || protocols[0] != "xai_video" {
+ t.Fatalf("expected generation-only xAI protocol, got %#v", protocols)
+ }
+}
+
func TestResolveRouteProtocolsKeepsSingleProtocolForGenerationOnlyModels(t *testing.T) {
tests := []struct {
name string
@@ -699,9 +737,24 @@ func TestIsRouteAllowedForTaskSeparatesChatAndImageProtocols(t *testing.T) {
if !IsRouteAllowedForTask(TaskTypeVideoGeneration, `["video_gen"]`, "xai_video") {
t.Fatalf("expected video generation task to allow xAI video protocol")
}
+ if IsRouteAllowedForTask(TaskTypeVideoGeneration, `["video_gen"]`, "xai_video_extensions") {
+ t.Fatal("video generation task must reject the xAI video extensions protocol")
+ }
if IsRouteAllowedForTask(TaskTypeVideoGeneration, `["chat"]`, "openai_responses") {
t.Fatalf("expected video generation task to reject chat protocol")
}
+ if !IsRouteAllowedForTask(TaskTypeVideoExtension, `["video_gen","video_extension"]`, "xai_video_extensions") {
+ t.Fatal("xAI video extensions route should support video extension")
+ }
+ if IsRouteAllowedForTask(TaskTypeVideoExtension, `["video_gen","video_extension"]`, "xai_video") {
+ t.Fatal("xAI video generations route must not serve video extension")
+ }
+ if IsRouteAllowedForTask(TaskTypeVideoExtension, `["video_gen","video_extension"]`, "gemini_interactions") {
+ t.Fatal("non-xAI video route must not support video extension")
+ }
+ if IsRouteAllowedForTask(TaskTypeVideoExtension, `["video_gen"]`, "xai_video_extensions") {
+ t.Fatal("video extension task must require the video_extension kind")
+ }
if IsRouteAllowedForTask(TaskTypeChat, `["video_gen"]`, "gemini_interactions") {
t.Fatalf("expected chat task to reject video generation protocol")
}
@@ -732,6 +785,12 @@ func TestDefaultRouteModelMatchesTaskFiltersByKind(t *testing.T) {
if defaultRouteModelMatchesTask(`["chat"]`, TaskTypeVideoGeneration) {
t.Fatal("expected video generation default route to reject chat model")
}
+ if !defaultRouteModelMatchesTask(`["video_gen","video_extension"]`, TaskTypeVideoExtension) {
+ t.Fatal("expected video extension default route to accept video extension model")
+ }
+ if defaultRouteModelMatchesTask(`["video_gen"]`, TaskTypeVideoExtension) {
+ t.Fatal("expected video extension default route to reject generation-only model")
+ }
}
func TestDisplayProtocolDefaultsJSONHidesLegacyInvalidDefaults(t *testing.T) {
diff --git a/backend/internal/application/channel/service_default_route.go b/backend/internal/application/channel/service_default_route.go
index 99b056f45..0eb3ab3a4 100644
--- a/backend/internal/application/channel/service_default_route.go
+++ b/backend/internal/application/channel/service_default_route.go
@@ -50,6 +50,8 @@ func defaultRouteModelMatchesTask(kindsJSON string, taskType string) bool {
return hasModelKind(kinds, modelKindImageEdit)
case TaskTypeVideoGeneration:
return hasModelKind(kinds, modelKindVideoGen)
+ case TaskTypeVideoExtension:
+ return hasModelKind(kinds, modelKindVideoExtension)
default:
return hasModelKind(kinds, modelKindChat)
}
diff --git a/backend/internal/application/channel/service_model.go b/backend/internal/application/channel/service_model.go
index 1006a34ff..39da9a42a 100644
--- a/backend/internal/application/channel/service_model.go
+++ b/backend/internal/application/channel/service_model.go
@@ -330,7 +330,7 @@ func (s *Service) ListActivePlatformModelNames(ctx context.Context) (map[string]
return keys, nil
}
-// SupportsVideoGeneration 返回平台模型是否具有真实可路由的视频生成能力。
+// SupportsVideoGeneration 返回平台模型是否具有可按时长计费的视频能力。
func (s *Service) SupportsVideoGeneration(ctx context.Context, platformModelName string) (bool, error) {
name, err := normalizePlatformModelName(platformModelName)
if err != nil {
@@ -344,7 +344,8 @@ func (s *Service) SupportsVideoGeneration(ctx context.Context, platformModelName
if item.ActiveSourceCount <= 0 || strings.TrimSpace(item.PlatformModelName) != name {
continue
}
- return hasModelKind(parseKinds(item.KindsJSON), modelKindVideoGen), nil
+ kinds := parseKinds(item.KindsJSON)
+ return hasModelKind(kinds, modelKindVideoGen) || hasModelKind(kinds, modelKindVideoExtension), nil
}
return false, nil
}
diff --git a/backend/internal/application/channel/service_view_normalization.go b/backend/internal/application/channel/service_view_normalization.go
index 14557d955..301653235 100644
--- a/backend/internal/application/channel/service_view_normalization.go
+++ b/backend/internal/application/channel/service_view_normalization.go
@@ -320,7 +320,7 @@ func validateKinds(kinds []string) bool {
switch kind {
case modelKindChat:
hasPrimary = true
- case modelKindAudio, modelKindImageGen, modelKindImageEdit, modelKindVideoGen:
+ case modelKindAudio, modelKindImageGen, modelKindImageEdit, modelKindVideoGen, modelKindVideoExtension:
hasPrimary = true
default:
return false
diff --git a/backend/internal/application/conversation/model_option_policy.go b/backend/internal/application/conversation/model_option_policy.go
index b158c6901..ad2ee3c2d 100644
--- a/backend/internal/application/conversation/model_option_policy.go
+++ b/backend/internal/application/conversation/model_option_policy.go
@@ -499,6 +499,8 @@ func sanitizeModelOptionValues(options map[string]interface{}, protocolKey strin
sanitizeOpenAIServiceTier(options)
case "xai_video":
llm.SanitizeXAIVideoOptions(options)
+ case "xai_video_extensions":
+ llm.SanitizeXAIVideoExtensionOptions(options)
case "openai_image_generations", "openai_image_edits":
value, ok := modelParamIntFromOption(options["partial_images"])
if !ok {
@@ -578,6 +580,8 @@ func modelOptionPolicyProtocolKey(protocol string) string {
return "xai_image_edits"
case llm.AdapterXAIVideo:
return "xai_video"
+ case llm.AdapterXAIVideoExtensions:
+ return "xai_video_extensions"
case llm.AdapterXAIResponses:
return "xai_responses"
default:
diff --git a/backend/internal/application/conversation/service_media_cancel.go b/backend/internal/application/conversation/service_media_cancel.go
index f925d7cbe..455806918 100644
--- a/backend/internal/application/conversation/service_media_cancel.go
+++ b/backend/internal/application/conversation/service_media_cancel.go
@@ -220,7 +220,8 @@ func mediaDurationSecondsFromOptions(options map[string]interface{}) int64 {
// withDefaultMediaVideoDuration 仅向明确支持 duration 参数的视频协议补齐产品缺省值。
// 其他协议仍以其返回的真实媒体时长为准,避免发送未声明的厂商参数。
func withDefaultMediaVideoDuration(options map[string]interface{}, protocol string) map[string]interface{} {
- if mediaDurationSecondsFromOptions(options) > 0 || llm.NormalizeAdapter(protocol) != llm.AdapterXAIVideo {
+ adapter := llm.NormalizeAdapter(protocol)
+ if mediaDurationSecondsFromOptions(options) > 0 || (adapter != llm.AdapterXAIVideo && adapter != llm.AdapterXAIVideoExtensions) {
return options
}
next := make(map[string]interface{}, len(options)+1)
diff --git a/backend/internal/application/conversation/service_media_video.go b/backend/internal/application/conversation/service_media_video.go
index ea170beef..33aae3734 100644
--- a/backend/internal/application/conversation/service_media_video.go
+++ b/backend/internal/application/conversation/service_media_video.go
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"strings"
"time"
@@ -23,11 +24,20 @@ import (
const maxMediaVideoInputImages = 1
+// MediaVideoTaskType 区分普通视频生成与基于源视频的扩展。
+type MediaVideoTaskType string
+
+const (
+ MediaVideoTaskGeneration MediaVideoTaskType = "video_generation"
+ MediaVideoTaskExtension MediaVideoTaskType = "video_extension"
+)
+
// MediaVideoInput 定义视频生成任务的应用层入参。
type MediaVideoInput struct {
UserID uint
ConversationID uint
RequestID string
+ TaskType MediaVideoTaskType
Prompt string
PlatformModelName string
Options map[string]interface{}
@@ -76,6 +86,11 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
if strings.TrimSpace(input.Prompt) == "" {
return nil, ErrMediaVideoPromptRequired
}
+ taskType := normalizeMediaVideoTaskType(input.TaskType)
+ routeTaskType := channel.TaskTypeVideoGeneration
+ if taskType == MediaVideoTaskExtension {
+ routeTaskType = channel.TaskTypeVideoExtension
+ }
platformModelName := strings.TrimSpace(input.PlatformModelName)
if platformModelName == "" {
@@ -86,7 +101,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
}
route, err := s.routeResolver.ResolveRoute(ctx, channel.ResolveRouteInput{
PlatformModelName: platformModelName,
- TaskType: channel.TaskTypeVideoGeneration,
+ TaskType: routeTaskType,
Scope: channel.RouteScopeUser,
UserID: input.UserID,
ConversationID: input.ConversationID,
@@ -98,6 +113,9 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
if !llm.IsVideoGenerationAdapter(route.Protocol) {
return nil, ErrMediaRouteProtocolMismatch
}
+ if taskType == MediaVideoTaskExtension && llm.NormalizeAdapter(route.Protocol) != llm.AdapterXAIVideoExtensions {
+ return nil, ErrMediaRouteProtocolMismatch
+ }
videoEndpoint := llm.DefaultEndpointForAdapter(route.Protocol)
if strings.TrimSpace(conversation.Model) != strings.TrimSpace(route.PlatformModelName) {
conversation.Model = strings.TrimSpace(route.PlatformModelName)
@@ -106,7 +124,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
return nil, err
}
}
- resolvedAttachments, videoInputParts, err := s.resolveMediaVideoInputs(ctx, input)
+ resolvedAttachments, videoInputParts, videoExtensionSource, err := s.resolveMediaVideoInputs(ctx, input, taskType)
if err != nil {
return nil, err
}
@@ -117,7 +135,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
RequestID: strings.TrimSpace(input.RequestID),
UserID: input.UserID,
ConversationID: input.ConversationID,
- TaskType: channel.TaskTypeVideoGeneration,
+ TaskType: routeTaskType,
Endpoint: videoEndpoint,
Provider: strings.TrimSpace(conversation.Provider),
ProviderProtocol: route.Protocol,
@@ -245,9 +263,11 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
}
}()
moderationFileIDs := make([]string, 0, len(resolvedAttachments))
- for _, item := range resolvedAttachments {
- if fileID := strings.TrimSpace(item.FileID); fileID != "" {
- moderationFileIDs = append(moderationFileIDs, fileID)
+ if taskType != MediaVideoTaskExtension {
+ for _, item := range resolvedAttachments {
+ if fileID := strings.TrimSpace(item.FileID); fileID != "" {
+ moderationFileIDs = append(moderationFileIDs, fileID)
+ }
}
}
moderationCoord = s.startModerationRun(ctx, SendMessageInput{
@@ -285,6 +305,11 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
if llm.NormalizeAdapter(route.Protocol) == llm.AdapterGeminiInteractions {
filteredOptions = withGeminiInteractionResponseType(filteredOptions, "video")
}
+ if llm.NormalizeAdapter(route.Protocol) == llm.AdapterXAIVideoExtensions {
+ llm.SanitizeXAIVideoExtensionOptions(filteredOptions)
+ } else if llm.NormalizeAdapter(route.Protocol) == llm.AdapterXAIVideo {
+ llm.SanitizeXAIVideoOptions(filteredOptions)
+ }
filteredOptions = withDefaultMediaVideoDuration(filteredOptions, route.Protocol)
durationSeconds := mediaDurationSecondsFromOptions(filteredOptions)
buildFailureResult := func(failure error, usage llm.Usage) *SendMessageResult {
@@ -311,7 +336,8 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
Role: "user",
Content: strings.TrimSpace(input.Prompt),
}},
- Options: filteredOptions,
+ Options: filteredOptions,
+ VideoExtensionSource: videoExtensionSource,
}
if len(videoInputParts) > 0 {
parts := make([]llm.ContentPart, 0, 1+len(videoInputParts))
@@ -357,7 +383,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
return buildFailureResult(retErr, mediaOutputUsage(output)), retErr
}
videoDurations, generatedDurationSeconds := resolveGeneratedVideoDurations(output.GeneratedVideos, durationSeconds)
- if generatedDurationSeconds > 0 {
+ if taskType != MediaVideoTaskExtension && generatedDurationSeconds > 0 {
durationSeconds = generatedDurationSeconds
}
@@ -498,7 +524,7 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
}
if moderationCoord != nil {
// Omni Moderation has no video modality. The prompt and optional input
- // image still participate in the same barrier as other media tasks.
+ // image participate in the barrier; an extension source is intentionally excluded.
s.completeModerationAfterSuccess(
ctx,
moderationCoord,
@@ -512,6 +538,13 @@ func (s *Service) StreamMediaVideo(ctx context.Context, input MediaVideoInput) (
return result, nil
}
+func normalizeMediaVideoTaskType(taskType MediaVideoTaskType) MediaVideoTaskType {
+ if taskType == MediaVideoTaskExtension {
+ return MediaVideoTaskExtension
+ }
+ return MediaVideoTaskGeneration
+}
+
func mediaVideoUserContentType(hasInputs bool) string {
if hasInputs {
return "mixed"
@@ -519,30 +552,66 @@ func mediaVideoUserContentType(hasInputs bool) string {
return "text"
}
-func (s *Service) resolveMediaVideoInputs(ctx context.Context, input MediaVideoInput) ([]AttachmentInput, []llm.ContentPart, error) {
+func (s *Service) resolveMediaVideoInputs(ctx context.Context, input MediaVideoInput, taskType MediaVideoTaskType) ([]AttachmentInput, []llm.ContentPart, *llm.ContentPart, error) {
if len(input.FileIDs) == 0 {
- return nil, nil, nil
+ if taskType == MediaVideoTaskExtension {
+ return nil, nil, nil, ErrMediaVideoInputInvalid
+ }
+ return nil, nil, nil, nil
}
attachments, err := s.resolveAttachments(ctx, input.UserID, input.FileIDs)
if err != nil {
- return nil, nil, err
+ return nil, nil, nil, err
}
if len(attachments) > maxMediaVideoInputImages {
- return nil, nil, ErrMediaVideoTooManyInputs
+ return nil, nil, nil, ErrMediaVideoTooManyInputs
+ }
+ if taskType == MediaVideoTaskExtension {
+ if len(attachments) != 1 {
+ return nil, nil, nil, ErrMediaVideoInputInvalid
+ }
+ part, readErr := s.readMediaVideoExtensionSource(ctx, input.UserID, attachments[0].FileID)
+ if readErr != nil {
+ return nil, nil, nil, readErr
+ }
+ return attachments, nil, &part, nil
}
parts := make([]llm.ContentPart, 0, len(attachments))
for _, attachment := range attachments {
if normalizeAttachmentKind(attachment.Kind, attachment.MimeType) != "image" {
- return nil, nil, ErrMediaVideoInputInvalid
+ return nil, nil, nil, ErrMediaVideoInputInvalid
}
part, readErr := s.readMediaImageEditFile(ctx, input.UserID, attachment.FileID)
if readErr != nil {
- return nil, nil, readErr
+ return nil, nil, nil, readErr
}
part.FileName = mediaImageEditInputFileName(attachment.FileName, part.MimeType)
parts = append(parts, part)
}
- return attachments, parts, nil
+ return attachments, parts, nil, nil
+}
+
+func (s *Service) readMediaVideoExtensionSource(ctx context.Context, userID uint, fileID string) (llm.ContentPart, error) {
+ content, err := s.OpenFileContent(ctx, userID, strings.TrimSpace(fileID))
+ if err != nil {
+ return llm.ContentPart{}, err
+ }
+ defer content.Reader.Close() //nolint:errcheck
+ limit := s.cfg.Snapshot().MaxUploadFileBytes
+ if limit <= 0 {
+ limit = 20 * 1024 * 1024
+ }
+ data, err := io.ReadAll(io.LimitReader(content.Reader, limit+1))
+ if err != nil {
+ return llm.ContentPart{}, err
+ }
+ if int64(len(data)) > limit {
+ return llm.ContentPart{}, ErrFileTooLarge
+ }
+ if detectGeneratedVideoMIME(data) != "video/mp4" {
+ return llm.ContentPart{}, ErrMediaVideoInputInvalid
+ }
+ return llm.ContentPart{Kind: llm.ContentPartVideo, MimeType: "video/mp4", Data: data, FileName: content.File.FileName}, nil
}
func mediaInputAttachmentRows(conversationID uint, userID uint, attachments []AttachmentInput) []model.Attachment {
diff --git a/backend/internal/application/settings/model_option_policy.go b/backend/internal/application/settings/model_option_policy.go
index 5c9bdb16e..df52127b6 100644
--- a/backend/internal/application/settings/model_option_policy.go
+++ b/backend/internal/application/settings/model_option_policy.go
@@ -21,6 +21,7 @@ var validModelOptionProtocolKeys = map[string]struct{}{
"xai_image": {},
"xai_image_edits": {},
"xai_video": {},
+ "xai_video_extensions": {},
"gemini_generate_content": {},
"google_image_generation": {},
"gemini_interactions": {},
diff --git a/backend/internal/application/settings/service.go b/backend/internal/application/settings/service.go
index 3844fd2ef..e0acbf653 100644
--- a/backend/internal/application/settings/service.go
+++ b/backend/internal/application/settings/service.go
@@ -237,7 +237,10 @@ func isLegacyDefaultModelOptionAllowedPaths(value string) bool {
)
legacyCombinedDefault := cloneStringSliceMap(legacyInteractionsWithoutSummaries)
legacyCombinedDefault["gemini_generate_content"] = previousGenerateContentDefault["gemini_generate_content"]
+ previousWithoutXAIVideoExtensions := cloneStringSliceMap(latestDefault)
+ delete(previousWithoutXAIVideoExtensions, "xai_video_extensions")
previousDefaults := []map[string][]string{
+ previousWithoutXAIVideoExtensions,
previousGenerateContentDefault,
previousInteractionsDefault,
previousCombinedDefault,
diff --git a/backend/internal/application/settings/service_seed_test.go b/backend/internal/application/settings/service_seed_test.go
index 27c82cd31..81816d6ea 100644
--- a/backend/internal/application/settings/service_seed_test.go
+++ b/backend/internal/application/settings/service_seed_test.go
@@ -206,6 +206,32 @@ func TestSeedAddsXAIVideoToPreviousDefaultModelOptionAllowedPaths(t *testing.T)
}
}
+func TestSeedAddsXAIVideoExtensionsToPreviousDefaultModelOptionAllowedPaths(t *testing.T) {
+ previousDefault := map[string][]string{}
+ if err := json.Unmarshal([]byte(config.DefaultModelOptionAllowedPathsJSON()), &previousDefault); err != nil {
+ t.Fatalf("decode current model option defaults: %v", err)
+ }
+ delete(previousDefault, "xai_video_extensions")
+ previousJSON, err := json.Marshal(previousDefault)
+ if err != nil {
+ t.Fatalf("encode previous model option defaults: %v", err)
+ }
+ repo := newSettingsSeedRepo(domainsettings.SystemSetting{
+ Namespace: "chat",
+ Key: "model_option_allowed_paths",
+ Value: string(previousJSON),
+ ValueType: "json",
+ })
+ service := NewService(repo, "")
+
+ if err := service.Seed(context.Background(), config.Config{}); err != nil {
+ t.Fatalf("seed settings: %v", err)
+ }
+ if got := repo.items["chat:model_option_allowed_paths"].Value; got != config.DefaultModelOptionAllowedPathsJSON() {
+ t.Fatalf("expected xAI video extensions defaults to be added, got %q", got)
+ }
+}
+
func TestSeedAddsGeminiThinkingSummariesToPreviousDefaultModelOptionAllowedPaths(t *testing.T) {
previousDefault := map[string][]string{}
if err := json.Unmarshal([]byte(config.DefaultModelOptionAllowedPathsJSON()), &previousDefault); err != nil {
diff --git a/backend/internal/infra/config/config.go b/backend/internal/infra/config/config.go
index 4361d7cb8..914b5d8da 100644
--- a/backend/internal/infra/config/config.go
+++ b/backend/internal/infra/config/config.go
@@ -168,6 +168,9 @@ func DefaultModelOptionAllowedPathsJSON() string {
"aspect_ratio",
"duration",
"resolution"
+ ],
+ "xai_video_extensions": [
+ "duration"
]
}`
}
diff --git a/backend/internal/infra/llm/adapter.go b/backend/internal/infra/llm/adapter.go
index 62e23b01d..6de55ba03 100644
--- a/backend/internal/infra/llm/adapter.go
+++ b/backend/internal/infra/llm/adapter.go
@@ -23,6 +23,7 @@ const (
AdapterXAIImage = "xai_image" // POST /v1/images/generations
AdapterXAIImageEdits = "xai_image_edits" // POST /v1/images/edits
AdapterXAIVideo = "xai_video" // POST /v1/videos/generations + GET /v1/videos/{request_id}
+ AdapterXAIVideoExtensions = "xai_video_extensions" // POST /v1/videos/extensions + GET /v1/videos/{request_id}
)
var (
@@ -64,7 +65,8 @@ func IsKnownAdapter(raw string) bool {
AdapterXAIResponses,
AdapterXAIImage,
AdapterXAIImageEdits,
- AdapterXAIVideo:
+ AdapterXAIVideo,
+ AdapterXAIVideoExtensions:
return true
default:
return false
@@ -75,7 +77,7 @@ func IsKnownAdapter(raw string) bool {
func IsImplementedAdapter(raw string) bool {
switch NormalizeAdapter(raw) {
case AdapterOpenAIResponses, AdapterOpenRouterChat, AdapterOpenRouterResponses, AdapterOpenAIChatCompletions, AdapterOpenAIImageGenerations, AdapterOpenAIImageEdits, AdapterXAIResponses,
- AdapterAnthropicMessages, AdapterGoogleGenerateContent, AdapterGoogleImageGeneration, AdapterGeminiInteractions, AdapterXAIImage, AdapterXAIImageEdits, AdapterXAIVideo:
+ AdapterAnthropicMessages, AdapterGoogleGenerateContent, AdapterGoogleImageGeneration, AdapterGeminiInteractions, AdapterXAIImage, AdapterXAIImageEdits, AdapterXAIVideo, AdapterXAIVideoExtensions:
return true
default:
return false
@@ -141,7 +143,7 @@ func IsImageEditAdapter(raw string) bool {
// IsVideoGenerationAdapter 返回协议是否属于独立视频生成链路。
func IsVideoGenerationAdapter(raw string) bool {
switch NormalizeAdapter(raw) {
- case AdapterGeminiInteractions, AdapterXAIVideo:
+ case AdapterGeminiInteractions, AdapterXAIVideo, AdapterXAIVideoExtensions:
return true
default:
return false
@@ -159,6 +161,8 @@ func DefaultEndpointForAdapter(adapter string) string {
return EndpointImageEdits
case AdapterXAIVideo:
return EndpointVideoGenerations
+ case AdapterXAIVideoExtensions:
+ return EndpointVideoExtensions
case AdapterGeminiInteractions:
return EndpointInteractions
default:
diff --git a/backend/internal/infra/llm/adapter_test.go b/backend/internal/infra/llm/adapter_test.go
index a5f41da96..118f4b30a 100644
--- a/backend/internal/infra/llm/adapter_test.go
+++ b/backend/internal/infra/llm/adapter_test.go
@@ -83,4 +83,13 @@ func TestXAIVideoAdapterCapabilities(t *testing.T) {
if got := DefaultEndpointForAdapter(AdapterXAIVideo); got != EndpointVideoGenerations {
t.Fatalf("expected xAI video endpoint, got %q", got)
}
+ if !IsKnownAdapter(AdapterXAIVideoExtensions) || !IsImplementedAdapter(AdapterXAIVideoExtensions) {
+ t.Fatalf("expected xAI video extensions adapter to be known and implemented")
+ }
+ if !IsVideoGenerationAdapter(AdapterXAIVideoExtensions) {
+ t.Fatalf("expected xAI video extensions adapter to use the video media pipeline")
+ }
+ if got := DefaultEndpointForAdapter(AdapterXAIVideoExtensions); got != EndpointVideoExtensions {
+ t.Fatalf("expected xAI video extensions endpoint, got %q", got)
+ }
}
diff --git a/backend/internal/infra/llm/client.go b/backend/internal/infra/llm/client.go
index f86e3b1eb..8405fb082 100644
--- a/backend/internal/infra/llm/client.go
+++ b/backend/internal/infra/llm/client.go
@@ -32,6 +32,8 @@ const (
EndpointImageEdits = "image_edits"
// EndpointVideoGenerations 表示异步视频生成端点。
EndpointVideoGenerations = "video_generations"
+ // EndpointVideoExtensions 表示 xAI 异步视频扩展端点。
+ EndpointVideoExtensions = "video_extensions"
// EndpointInteractions 表示 Gemini Interactions API 端点。
EndpointInteractions = "interactions"
)
@@ -96,12 +98,13 @@ func resolveStreamIdleTimeout(ms int) time.Duration {
const (
ContentPartText = "text" // 纯文本
ContentPartImage = "image" // 图片(原始字节,序列化时 base64 编码)
+ ContentPartVideo = "video" // 视频(原始字节,仅供支持视频输入的 adapter 使用)
ContentPartFile = "file" // 文件提取文本(前端解析后注入)
)
// ContentPart 表示多模态消息中的一个内容片段。
type ContentPart struct {
- Kind string // text | image | file
+ Kind string // text | image | video | file
Text string // Kind=text 或 Kind=file 时的文本内容
MimeType string // Kind=image 时的 MIME 类型(如 "image/jpeg")
Data []byte // Kind=image 时的原始字节(发送时 base64 编码)
@@ -154,6 +157,8 @@ type GenerateInput struct {
ResponsesBackground bool
// ImageEditMask 仅供图片编辑 adapter 使用,表示透明区域掩码。
ImageEditMask *ContentPart
+ // VideoExtensionSource 仅供视频扩展 adapter 使用,表示待扩展的源视频。
+ VideoExtensionSource *ContentPart
}
// ToolDefinition 是模型可调用工具的统一声明。
@@ -855,6 +860,7 @@ func NewClient(outboundPolicy security.OutboundPolicy) *Client {
AdapterXAIImage: &xAIImageAdapter{client: client},
AdapterXAIImageEdits: &xAIImageEditsAdapter{client: client},
AdapterXAIVideo: &xAIVideoAdapter{client: client},
+ AdapterXAIVideoExtensions: &xAIVideoExtensionsAdapter{client: client},
AdapterAnthropicMessages: &anthropicMessagesAdapter{client: client},
AdapterGoogleGenerateContent: &geminiGenerateContentAdapter{client: client},
AdapterGoogleImageGeneration: &geminiImageGenerationAdapter{client: client},
@@ -1681,6 +1687,8 @@ func normalizeEndpoint(raw string) string {
return EndpointImageEdits
case EndpointVideoGenerations:
return EndpointVideoGenerations
+ case EndpointVideoExtensions:
+ return EndpointVideoExtensions
case EndpointInteractions:
return EndpointInteractions
default:
diff --git a/backend/internal/infra/llm/endpoint_url_test.go b/backend/internal/infra/llm/endpoint_url_test.go
index dd26e3cd1..66661971e 100644
--- a/backend/internal/infra/llm/endpoint_url_test.go
+++ b/backend/internal/infra/llm/endpoint_url_test.go
@@ -51,6 +51,12 @@ func TestBuildOpenAICompatibleURLsRespectVersionedBasePath(t *testing.T) {
endpoint: EndpointVideoGenerations,
want: "https://api.x.ai/v1/videos/generations",
},
+ {
+ name: "xai video extensions endpoint",
+ baseURL: "https://api.x.ai/v1",
+ endpoint: EndpointVideoExtensions,
+ want: "https://api.x.ai/v1/videos/extensions",
+ },
{
name: "xai proxy plain base gets v1 image endpoint",
baseURL: "https://proxy.example.com",
diff --git a/backend/internal/infra/llm/openai.go b/backend/internal/infra/llm/openai.go
index e9c036e87..7757afb3b 100644
--- a/backend/internal/infra/llm/openai.go
+++ b/backend/internal/infra/llm/openai.go
@@ -355,6 +355,8 @@ func buildOpenAIRequestURL(baseURL string, endpoint string) string {
return buildVersionedEndpointURL(baseURL, "v1", "/images/edits")
case EndpointVideoGenerations:
return buildVersionedEndpointURL(baseURL, "v1", "/videos/generations")
+ case EndpointVideoExtensions:
+ return buildVersionedEndpointURL(baseURL, "v1", "/videos/extensions")
default:
return buildVersionedEndpointURL(baseURL, "v1", "/responses")
}
diff --git a/backend/internal/infra/llm/xai_videos.go b/backend/internal/infra/llm/xai_videos.go
index 7a1865a8a..b6d7d06e1 100644
--- a/backend/internal/infra/llm/xai_videos.go
+++ b/backend/internal/infra/llm/xai_videos.go
@@ -25,6 +25,9 @@ func (a *xAIVideoAdapter) Name() string { return AdapterXAIVideo }
func (a *xAIVideoAdapter) Generate(ctx context.Context, route RouteConfig, input GenerateInput) (*GenerateOutput, error) {
route.Protocol = AdapterXAIVideo
+ if input.VideoExtensionSource != nil {
+ return nil, fmt.Errorf("xai video generation protocol does not accept an extension source")
+ }
route.Endpoint = EndpointVideoGenerations
return a.client.generateXAIVideo(ctx, route, input)
}
@@ -43,9 +46,39 @@ func (a *xAIVideoAdapter) ListModels(ctx context.Context, route RouteConfig) ([]
return a.client.listModelsOpenAICompatible(ctx, route)
}
+// xAIVideoExtensionsAdapter 实现 xAI 异步视频扩展协议。
+type xAIVideoExtensionsAdapter struct {
+ client *Client
+}
+
+func (a *xAIVideoExtensionsAdapter) Name() string { return AdapterXAIVideoExtensions }
+
+func (a *xAIVideoExtensionsAdapter) Generate(ctx context.Context, route RouteConfig, input GenerateInput) (*GenerateOutput, error) {
+ if input.VideoExtensionSource == nil {
+ return nil, fmt.Errorf("xai video extension protocol requires an MP4 source")
+ }
+ route.Protocol = AdapterXAIVideoExtensions
+ route.Endpoint = EndpointVideoExtensions
+ return a.client.generateXAIVideo(ctx, route, input)
+}
+
+func (a *xAIVideoExtensionsAdapter) GenerateStream(
+ context.Context,
+ RouteConfig,
+ GenerateInput,
+ func(GenerateStreamEvent) error,
+) (*GenerateOutput, error) {
+ return nil, fmt.Errorf("%w: %s", ErrUnsupportedStream, AdapterXAIVideoExtensions)
+}
+
+func (a *xAIVideoExtensionsAdapter) ListModels(ctx context.Context, route RouteConfig) ([]ModelItem, error) {
+ route.Protocol = AdapterXAIVideoExtensions
+ return a.client.listModelsOpenAICompatible(ctx, route)
+}
+
// generateXAIVideo 提交视频任务,并在同一请求超时范围内轮询官方结果端点。
func (c *Client) generateXAIVideo(ctx context.Context, route RouteConfig, input GenerateInput) (*GenerateOutput, error) {
- requestBody, debugBody, err := buildXAIVideoRequestBody(route.UpstreamModel, input)
+ requestBody, debugBody, err := buildXAIVideoSubmissionBody(route.UpstreamModel, input)
if err != nil {
return nil, err
}
@@ -53,7 +86,7 @@ func (c *Client) generateXAIVideo(ctx context.Context, route RouteConfig, input
if err != nil {
return nil, err
}
- requestURL := buildOpenAIRequestURL(route.BaseURL, EndpointVideoGenerations)
+ requestURL := buildOpenAIRequestURL(route.BaseURL, route.Endpoint)
if requestURL == "" {
return nil, fmt.Errorf("invalid base url")
}
@@ -89,6 +122,42 @@ func (c *Client) generateXAIVideo(ctx context.Context, route RouteConfig, input
return c.pollXAIVideoResult(requestCtx, route, requestID, generatedMediaDurationSeconds(requestBody["duration"]))
}
+func buildXAIVideoSubmissionBody(model string, input GenerateInput) (map[string]interface{}, []byte, error) {
+ if input.VideoExtensionSource != nil {
+ return buildXAIVideoExtensionRequestBody(model, input)
+ }
+ return buildXAIVideoRequestBody(model, input)
+}
+
+func buildXAIVideoExtensionRequestBody(model string, input GenerateInput) (map[string]interface{}, []byte, error) {
+ prompt := strings.TrimSpace(buildOpenAIImageGenerationPrompt(input.Messages))
+ source := input.VideoExtensionSource
+ if prompt == "" {
+ return nil, nil, fmt.Errorf("video extension prompt required")
+ }
+ if source == nil || source.Kind != ContentPartVideo || strings.ToLower(strings.TrimSpace(source.MimeType)) != "video/mp4" || len(source.Data) == 0 {
+ return nil, nil, fmt.Errorf("video extension source must be a non-empty MP4 video")
+ }
+ payload := map[string]interface{}{
+ "model": strings.TrimSpace(model),
+ "prompt": prompt,
+ "video": map[string]interface{}{
+ "url": "data:video/mp4;base64," + base64.StdEncoding.EncodeToString(source.Data),
+ },
+ }
+ applyXAIVideoExtensionParams(payload, input.Options)
+ debugPayload := map[string]interface{}{
+ "model": payload["model"],
+ "prompt": payload["prompt"],
+ "video_source": "data:video/mp4;base64,[REDACTED]",
+ }
+ if duration, ok := payload["duration"]; ok {
+ debugPayload["duration"] = duration
+ }
+ debugBody, _ := json.Marshal(debugPayload)
+ return payload, debugBody, nil
+}
+
func newXAIMediaRequest(ctx context.Context, method string, requestURL string, payload []byte, route RouteConfig) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, requestURL, bytes.NewReader(payload))
if err != nil {
@@ -158,6 +227,32 @@ func applyXAIVideoParams(payload map[string]interface{}, options map[string]inte
}
}
+func applyXAIVideoExtensionParams(payload map[string]interface{}, options map[string]interface{}) {
+ normalized := maps.Clone(options)
+ SanitizeXAIVideoExtensionOptions(normalized)
+ if duration, ok := normalized["duration"]; ok {
+ payload["duration"] = duration
+ }
+}
+
+// SanitizeXAIVideoExtensionOptions 仅保留 xAI 视频扩展支持的时长参数。
+func SanitizeXAIVideoExtensionOptions(options map[string]interface{}) {
+ if len(options) == 0 {
+ return
+ }
+ for key := range options {
+ if key != "duration" {
+ delete(options, key)
+ }
+ }
+ duration, ok := xAIMediaIntegerOption(options, "duration")
+ if !ok || duration < 2 || duration > 10 {
+ delete(options, "duration")
+ return
+ }
+ options["duration"] = duration
+}
+
// SanitizeXAIVideoOptions 将 xAI 视频协议参数收敛为实际会上送的规范值。
// Application 层复用该函数,保证有效参数、计费和 adapter 请求一致。
func SanitizeXAIVideoOptions(options map[string]interface{}) {
diff --git a/backend/internal/infra/llm/xai_videos_test.go b/backend/internal/infra/llm/xai_videos_test.go
index 0489c952a..4dd25ce30 100644
--- a/backend/internal/infra/llm/xai_videos_test.go
+++ b/backend/internal/infra/llm/xai_videos_test.go
@@ -85,6 +85,45 @@ func TestBuildXAIVideoRequestBodyDropsUnsupportedParams(t *testing.T) {
}
}
+func TestBuildXAIVideoExtensionRequestBody(t *testing.T) {
+ payload, debugBody, err := buildXAIVideoExtensionRequestBody("grok-imagine-video", GenerateInput{
+ Messages: []Message{{Role: "user", Content: "Continue the camera movement"}},
+ VideoExtensionSource: &ContentPart{Kind: ContentPartVideo, MimeType: "video/mp4", Data: []byte("source-video")},
+ Options: map[string]interface{}{"duration": 8, "aspect_ratio": "16:9", "resolution": "1080p"},
+ })
+ if err != nil {
+ t.Fatalf("build xAI video extension request: %v", err)
+ }
+ if payload["duration"] != 8 || payload["prompt"] != "Continue the camera movement" {
+ t.Fatalf("unexpected video extension payload: %#v", payload)
+ }
+ if _, ok := payload["aspect_ratio"]; ok {
+ t.Fatalf("video extension must not forward generation-only options: %#v", payload)
+ }
+ video := asMap(payload["video"])
+ if !strings.HasPrefix(getString(video["url"]), "data:video/mp4;base64,") {
+ t.Fatalf("expected MP4 data URL, got %#v", video)
+ }
+ if strings.Contains(string(debugBody), "c291cmNlLXZpZGVv") {
+ t.Fatalf("debug body must redact source video: %s", debugBody)
+ }
+}
+
+func TestBuildXAIVideoExtensionRequestBodyRejectsInvalidSourceAndDuration(t *testing.T) {
+ payload, _, err := buildXAIVideoExtensionRequestBody("grok-imagine-video", GenerateInput{
+ Messages: []Message{{Role: "user", Content: "Continue"}},
+ VideoExtensionSource: &ContentPart{Kind: ContentPartVideo, MimeType: "video/webm", Data: []byte("source")},
+ })
+ if err == nil || payload != nil {
+ t.Fatalf("expected invalid source error, got payload=%#v err=%v", payload, err)
+ }
+ options := map[string]interface{}{"duration": 11, "resolution": "720p"}
+ SanitizeXAIVideoExtensionOptions(options)
+ if len(options) != 0 {
+ t.Fatalf("unsupported extension options must be removed: %#v", options)
+ }
+}
+
func TestXAIVideoPollDelayClampsUnsafeValues(t *testing.T) {
tests := map[string]time.Duration{
"": time.Second,
diff --git a/backend/internal/transport/http/channel/dto_request.go b/backend/internal/transport/http/channel/dto_request.go
index e73793108..85b5238ba 100644
--- a/backend/internal/transport/http/channel/dto_request.go
+++ b/backend/internal/transport/http/channel/dto_request.go
@@ -189,5 +189,5 @@ type ImportUpstreamModelItemRequest struct {
// ModelProbeRequest 后台模型连通性测试请求。
type ModelProbeRequest struct {
- TaskType string `json:"taskType,omitempty" binding:"omitempty,oneof=chat image_generation image_edit video_generation"`
+ TaskType string `json:"taskType,omitempty" binding:"omitempty,oneof=chat image_generation image_edit video_generation video_extension"`
}
diff --git a/backend/internal/transport/http/conversation/dto_request.go b/backend/internal/transport/http/conversation/dto_request.go
index b8562efdb..c4e316532 100644
--- a/backend/internal/transport/http/conversation/dto_request.go
+++ b/backend/internal/transport/http/conversation/dto_request.go
@@ -133,6 +133,18 @@ type MediaVideoRequest struct {
BranchReason string `json:"branchReason,omitempty" binding:"omitempty,oneof=default retry edit"`
}
+// MediaVideoExtensionRequest 视频扩展请求。
+type MediaVideoExtensionRequest struct {
+ Prompt string `json:"prompt" binding:"required"`
+ Model string `json:"model,omitempty" binding:"omitempty,max=128"`
+ Options map[string]interface{} `json:"options,omitempty"`
+ ClientRunID string `json:"clientRunID,omitempty" binding:"omitempty,max=64"`
+ SourceVideoFileID string `json:"sourceVideoFileID" binding:"required,max=128"`
+ ParentMessagePublicID string `json:"parentMessagePublicID,omitempty" binding:"omitempty,max=32"`
+ SourceMessagePublicID string `json:"sourceMessagePublicID,omitempty" binding:"omitempty,max=32"`
+ BranchReason string `json:"branchReason,omitempty" binding:"omitempty,oneof=default retry edit"`
+}
+
// SetMessageFeedbackRequest 设置消息反馈请求。
type SetMessageFeedbackRequest struct {
Feedback string `json:"feedback,omitempty" binding:"omitempty,oneof=up down"`
diff --git a/backend/internal/transport/http/conversation/handler_media.go b/backend/internal/transport/http/conversation/handler_media.go
index d8f006eea..fd1f273f5 100644
--- a/backend/internal/transport/http/conversation/handler_media.go
+++ b/backend/internal/transport/http/conversation/handler_media.go
@@ -29,6 +29,38 @@ func (h *Handler) StreamImageEdit(c *gin.Context) {
// StreamVideoGeneration 处理会话内视频生成流式状态接口。
func (h *Handler) StreamVideoGeneration(c *gin.Context) {
+ h.streamMediaVideo(c, appconversation.MediaVideoTaskGeneration)
+}
+
+// StreamVideoExtension 处理会话内视频扩展流式状态接口。
+// @Summary 扩展会话视频
+// @Tags Conversations
+// @Accept json
+// @Produce application/x-ndjson
+// @Param id path string true "会话 Public ID"
+// @Param payload body MediaVideoExtensionRequest true "视频扩展请求"
+// @Success 200 {string} string "NDJSON stream"
+// @Failure 400 {object} response.Envelope
+// @Failure 401 {object} response.Envelope
+// @Failure 404 {object} response.Envelope
+// @Router /conversations/{id}/media/videos/extensions/stream [post]
+func (h *Handler) StreamVideoExtension(c *gin.Context) {
+ h.streamMediaVideo(c, appconversation.MediaVideoTaskExtension)
+}
+
+type mediaVideoTransportRequest struct {
+ Prompt string
+ Model string
+ Options map[string]interface{}
+ ClientRunID string
+ FileIDs []string
+ ParentMessagePublicID string
+ SourceMessagePublicID string
+ BranchReason string
+}
+
+// streamMediaVideo 统一视频生成与扩展的 HTTP、授权和事件转发流程。
+func (h *Handler) streamMediaVideo(c *gin.Context, taskType appconversation.MediaVideoTaskType) {
userID := middleware.MustUserID(c)
publicID, err := stringParam(c, "id")
if err != nil {
@@ -44,10 +76,39 @@ func (h *Handler) StreamVideoGeneration(c *gin.Context) {
response.Error(c, http.StatusInternalServerError, "load conversation failed")
return
}
- var req MediaVideoRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.Error(c, http.StatusBadRequest, err.Error())
- return
+ var req mediaVideoTransportRequest
+ if taskType == appconversation.MediaVideoTaskExtension {
+ var payload MediaVideoExtensionRequest
+ if err := c.ShouldBindJSON(&payload); err != nil {
+ response.Error(c, http.StatusBadRequest, err.Error())
+ return
+ }
+ req = mediaVideoTransportRequest{
+ Prompt: payload.Prompt,
+ Model: payload.Model,
+ Options: payload.Options,
+ ClientRunID: payload.ClientRunID,
+ FileIDs: []string{payload.SourceVideoFileID},
+ ParentMessagePublicID: payload.ParentMessagePublicID,
+ SourceMessagePublicID: payload.SourceMessagePublicID,
+ BranchReason: payload.BranchReason,
+ }
+ } else {
+ var payload MediaVideoRequest
+ if err := c.ShouldBindJSON(&payload); err != nil {
+ response.Error(c, http.StatusBadRequest, err.Error())
+ return
+ }
+ req = mediaVideoTransportRequest{
+ Prompt: payload.Prompt,
+ Model: payload.Model,
+ Options: payload.Options,
+ ClientRunID: payload.ClientRunID,
+ FileIDs: payload.FileIDs,
+ ParentMessagePublicID: payload.ParentMessagePublicID,
+ SourceMessagePublicID: payload.SourceMessagePublicID,
+ BranchReason: payload.BranchReason,
+ }
}
req.ClientRunID = appconversation.EnsureMessageGenerationRunID(req.ClientRunID)
req.Options = sanitizeMessageOptions(req.Options)
@@ -67,6 +128,7 @@ func (h *Handler) StreamVideoGeneration(c *gin.Context) {
UserID: userID,
ConversationID: conversation.ID,
RequestID: middleware.MustRequestID(c),
+ TaskType: taskType,
Prompt: req.Prompt,
PlatformModelName: req.Model,
Options: req.Options,
@@ -306,7 +368,7 @@ func mediaImageBillingInput(
func mediaVideoBillingInput(
userID uint,
conversation *model.Conversation,
- req *MediaVideoRequest,
+ req *mediaVideoTransportRequest,
result *appconversation.SendMessageResult,
) appconversation.SendMessageBillingInput {
input := appconversation.SendMessageBillingInput{
diff --git a/backend/internal/transport/http/conversation/router.go b/backend/internal/transport/http/conversation/router.go
index d7d2e62fe..87b1e2f39 100644
--- a/backend/internal/transport/http/conversation/router.go
+++ b/backend/internal/transport/http/conversation/router.go
@@ -39,6 +39,7 @@ func (m *Module) RegisterRoutes(authRequired *gin.RouterGroup) {
authRequired.POST("/conversations/:id/media/images/generations/stream", m.Handler.StreamImageGeneration)
authRequired.POST("/conversations/:id/media/images/edits/stream", m.Handler.StreamImageEdit)
authRequired.POST("/conversations/:id/media/videos/generations/stream", m.Handler.StreamVideoGeneration)
+ authRequired.POST("/conversations/:id/media/videos/extensions/stream", m.Handler.StreamVideoExtension)
authRequired.GET("/context-artifacts/:id", m.Handler.GetContextArtifact)
authRequired.GET("/conversation-runs/:run_id/stream", m.Handler.ResumeMessageGenerationStream)
authRequired.POST("/conversation-runs/:run_id/cancel", m.Handler.CancelMessageGeneration)
diff --git a/frontend/features/admin/api/llm.types.ts b/frontend/features/admin/api/llm.types.ts
index 1ec5fde40..f53798b12 100644
--- a/frontend/features/admin/api/llm.types.ts
+++ b/frontend/features/admin/api/llm.types.ts
@@ -13,8 +13,8 @@ import type {
ModelDataResponse,
ModelDisplayGroupDataResponse,
ModelDisplayGroupResponse,
- ModelIconAssetResponse,
ModelIconAssetListItemResponse,
+ ModelIconAssetResponse,
ModelProbeBatchResponse,
ModelProbeDebugRequestResponse,
ModelProbeDebugResponse,
@@ -23,13 +23,16 @@ import type {
ModelResponse,
ModelUpstreamSourceDataResponse,
ModelUpstreamSourceResponse,
+ ModelVendorDataResponse,
+ ModelVendorDeleteConflictDetails,
+ ModelVendorResponse,
ReorderModelsRequest,
SetModelProtocolsRequest,
SetModelsDisplayGroupRequest,
UpdateModelDisplayGroupRequest,
UpdateModelRequest,
- UpdateModelVendorRequest,
UpdateModelUpstreamSourceRequest,
+ UpdateModelVendorRequest,
UpdateUpstreamRequest,
UpsertUpstreamModelRequest,
UpstreamAPIKeyResponse,
@@ -39,9 +42,6 @@ import type {
UpstreamRemoteModelResponse,
UpstreamRemoteModelsResponse,
UpstreamResponse,
- ModelVendorDataResponse,
- ModelVendorDeleteConflictDetails,
- ModelVendorResponse,
} from "@deeix/api-contract";
export type AdminLLMStatus = "active" | "inactive";
@@ -61,7 +61,8 @@ export type AdminLLMAdapter =
| "xai_responses"
| "xai_image"
| "xai_image_edits"
- | "xai_video";
+ | "xai_video"
+ | "xai_video_extensions";
export type AdminLLMModelVendor = string;
export type AdminLLMCompatible =
| "openai"
diff --git a/frontend/features/admin/components/sections/conversation/admin-conversation.tsx b/frontend/features/admin/components/sections/conversation/admin-conversation.tsx
index d899e6210..272b63238 100644
--- a/frontend/features/admin/components/sections/conversation/admin-conversation.tsx
+++ b/frontend/features/admin/components/sections/conversation/admin-conversation.tsx
@@ -1,13 +1,9 @@
"use client";
-import * as React from "react";
import { CircleHelp, Download, Save } from "lucide-react";
import { useTranslations } from "next-intl";
+import * as React from "react";
import { toast } from "sonner";
-
-import { TaskModelField, type ModelOption } from "../shared/task-model-field";
-import { SettingsFieldEditor } from "../shared/settings-runtime-panel";
-import { ConversationPromptPresetsSection } from "@/features/admin/components/sections/conversation/conversation-prompt-presets";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -20,39 +16,42 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
-import { downloadBlob, readExportManifest } from "@/shared/lib/export-download";
-import {
- SettingsFieldInset,
- SettingsFieldItem,
- SettingsFieldList,
- SettingsFieldRow,
- SettingsPage,
- SettingsSection,
- SettingsSectionSeparator,
-} from "@/shared/components/settings-layout";
import { exportAllConversations, getAdminReferenceData, listAdminSettings, patchAdminSettings } from "@/features/admin/api";
+import { ConversationPromptPresetsSection } from "@/features/admin/components/sections/conversation/conversation-prompt-presets";
import {
buildConversationSettingsFields,
CONVERSATION_DEFAULT_MODEL_SYSTEM,
CONVERSATION_TASK_MODEL_FOLLOW,
+ type ConversationSettingsField,
fieldID,
flattenConversationSettings,
resolveVisibleConversationFields,
toEditorField,
- type ConversationSettingsField,
} from "@/features/admin/model/conversation-settings";
-import { resolveAdminErrorMessage } from "@/features/admin/utils/admin-error";
import { buildTaskModelOptions } from "@/features/admin/model/task-model-options";
+import { resolveAdminErrorMessage } from "@/features/admin/utils/admin-error";
import type { PatchSettingItem } from "@/shared/api/settings.types";
+import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
+import {
+ SettingsFieldInset,
+ SettingsFieldItem,
+ SettingsFieldList,
+ SettingsFieldRow,
+ SettingsPage,
+ SettingsSection,
+ SettingsSectionSeparator,
+} from "@/shared/components/settings-layout";
+import { downloadBlob, readExportManifest } from "@/shared/lib/export-download";
import {
HARD_DENIED_MODEL_OPTION_PATHS,
MODEL_OPTION_POLICY_PROTOCOL_LABELS,
MODEL_OPTION_POLICY_PROTOCOLS,
+ type ModelOptionRuleMap,
parseModelOptionRuleMap,
uniqueModelOptionPaths,
- type ModelOptionRuleMap,
} from "@/shared/lib/model-option-policy";
+import { SettingsFieldEditor } from "../shared/settings-runtime-panel";
+import { type ModelOption, TaskModelField } from "../shared/task-model-field";
function isModelOptionPolicyField(field: ConversationSettingsField): boolean {
return field.section === "optionPassthrough";
@@ -288,6 +287,9 @@ generationConfig.safetySettings.threshold`}
"duration",
"resolution"
],
+ "xai_video_extensions": [
+ "duration"
+ ],
"openai_chat_completions": [
"service_tier",
"thinking.type"
diff --git a/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx b/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx
index f6293d93b..ac0ddf43a 100644
--- a/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx
+++ b/frontend/features/admin/components/sections/models/models-capabilities-presets.tsx
@@ -1,12 +1,11 @@
"use client";
-import { useState } from "react";
import { Check, Copy, Search } from "lucide-react";
+import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
@@ -15,11 +14,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { cn } from "@/lib/utils";
import type { AdminLLMAdapter, AdminLLMModelDTO } from "@/features/admin/api/llm.types";
-import { parseProtocolsJSON } from "@/shared/lib/model-protocols";
+import { cn } from "@/lib/utils";
import { MODEL_OPTION_POLICY_PROTOCOL_LABELS, resolveModelOptionPolicyProtocol } from "@/shared/lib/model-option-policy";
+import { parseProtocolsJSON } from "@/shared/lib/model-protocols";
type CapabilityPreset = {
id: string;
@@ -62,6 +62,7 @@ const XAI_IMAGE_ASPECT_RATIOS = [
const XAI_IMAGE_RESOLUTIONS = ["1k", "2k"];
const XAI_VIDEO_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"];
const XAI_VIDEO_DURATIONS = Array.from({ length: 15 }, (_, index) => String(index + 1));
+const XAI_VIDEO_EXTENSION_DURATIONS = Array.from({ length: 9 }, (_, index) => String(index + 2));
const XAI_VIDEO_RESOLUTIONS = ["480p", "720p", "1080p"];
const XAI_IMAGE_OPTION_CONTROLS = [
@@ -581,6 +582,37 @@ const MODEL_CAPABILITY_PRESETS: CapabilityPreset[] = [
],
},
},
+ {
+ id: "xai_video_extensions",
+ protocol: "xai_video_extensions",
+ payload: {
+ defaultOptions: { duration: 6 },
+ optionControls: [
+ {
+ path: "duration",
+ type: "select",
+ label: "Extension duration (seconds)",
+ description: "Additional video duration from 2 to 10 seconds.",
+ options: XAI_VIDEO_EXTENSION_DURATIONS,
+ },
+ ],
+ mediaTasks: {
+ video_extension: {
+ enabled: true,
+ defaultOptions: { duration: 6 },
+ optionControls: [
+ {
+ path: "duration",
+ type: "select",
+ label: "Extension duration (seconds)",
+ description: "Additional video duration from 2 to 10 seconds.",
+ options: XAI_VIDEO_EXTENSION_DURATIONS,
+ },
+ ],
+ },
+ },
+ },
+ },
];
const MODEL_CAPABILITY_PRESET_ORDER = new Map(MODEL_CAPABILITY_PRESETS.map((preset, index) => [preset.id, index]));
diff --git a/frontend/features/admin/components/sections/models/models-order-sheet.tsx b/frontend/features/admin/components/sections/models/models-order-sheet.tsx
index 60295a30d..91055948d 100644
--- a/frontend/features/admin/components/sections/models/models-order-sheet.tsx
+++ b/frontend/features/admin/components/sections/models/models-order-sheet.tsx
@@ -83,7 +83,7 @@ function KindBadges({ kindsJSON }: { kindsJSON: string }) {
{kinds.map((kind) => (
- {["chat", "audio", "image_gen", "image_edit", "video_gen"].includes(kind)
+ {["chat", "audio", "image_gen", "image_edit", "video_gen", "video_extension"].includes(kind)
? t(`kinds.${kind}`)
: kind}
diff --git a/frontend/features/admin/components/sections/models/models-sheet.tsx b/frontend/features/admin/components/sections/models/models-sheet.tsx
index e18bd0697..231e9cf1d 100644
--- a/frontend/features/admin/components/sections/models/models-sheet.tsx
+++ b/frontend/features/admin/components/sections/models/models-sheet.tsx
@@ -391,23 +391,65 @@ export function ModelSheet({ open, mode, target, models, vendors, displayGroups,
}
function handleBindRowModelChange(rowID: string, upstreamModelID: string) {
- setBindRows((current) =>
- current.map((row) => {
- if (row.id !== rowID) {
- return row;
- }
- const upstreamModels = upstreamModelsByID[row.draft.upstreamID] ?? [];
- const selected = upstreamModels.find((item) => String(item.id) === upstreamModelID);
- return {
- ...row,
- draft: {
- ...row.draft,
- upstreamModelID,
- protocol: selected?.suggestedProtocol ?? "",
- },
- };
- }),
- );
+ const targetRow = bindRows.find((row) => row.id === rowID);
+ const selected = targetRow
+ ? (upstreamModelsByID[targetRow.draft.upstreamID] ?? []).find(
+ (item) => String(item.id) === upstreamModelID,
+ )
+ : undefined;
+ if (selected?.suggestedProtocol === "xai_video") {
+ setForm((current) => ({
+ ...current,
+ kinds: Array.from(new Set([...current.kinds, "video_gen", "video_extension"])),
+ }));
+ }
+ setBindRows((current) => {
+ const currentTargetRow = current.find((row) => row.id === rowID);
+ if (!currentTargetRow) {
+ return current;
+ }
+ const protocols: AdminLLMAdapter[] = selected?.suggestedProtocol === "xai_video"
+ ? ["xai_video", "xai_video_extensions"]
+ : selected?.suggestedProtocol
+ ? [selected.suggestedProtocol]
+ : [];
+ const existingProtocols = new Set(
+ current
+ .filter(
+ (row) =>
+ row.id !== rowID &&
+ row.draft.upstreamID === currentTargetRow.draft.upstreamID &&
+ row.draft.upstreamModelID === upstreamModelID,
+ )
+ .map((row) => row.draft.protocol)
+ .filter(Boolean),
+ );
+ const missingProtocols = protocols.filter((protocol) => !existingProtocols.has(protocol));
+ const primaryProtocol = missingProtocols[0] ?? "";
+ const companionRows = missingProtocols.slice(1).map((protocol) =>
+ createModelSourceBindDraftRow({
+ ...currentTargetRow.draft,
+ upstreamModelID,
+ protocol,
+ }),
+ );
+
+ return current.flatMap((row) =>
+ row.id === rowID
+ ? [
+ {
+ ...row,
+ draft: {
+ ...row.draft,
+ upstreamModelID,
+ protocol: primaryProtocol,
+ },
+ },
+ ...companionRows,
+ ]
+ : [row],
+ );
+ });
}
function setBindRowField
(
diff --git a/frontend/features/admin/components/sections/models/models-table.tsx b/frontend/features/admin/components/sections/models/models-table.tsx
index cbfdce887..1528421a2 100644
--- a/frontend/features/admin/components/sections/models/models-table.tsx
+++ b/frontend/features/admin/components/sections/models/models-table.tsx
@@ -176,7 +176,7 @@ function KindsBadges({ kindsJson }: { kindsJson: string | null | undefined }) {
{kinds.map((kind) => (
- {["chat", "audio", "image_gen", "image_edit", "video_gen"].includes(kind)
+ {["chat", "audio", "image_gen", "image_edit", "video_gen", "video_extension"].includes(kind)
? t(`kinds.${kind}`)
: kind}
diff --git a/frontend/features/admin/components/sections/upstreams/upstreams-sheet.tsx b/frontend/features/admin/components/sections/upstreams/upstreams-sheet.tsx
index 17fa6a4e7..25ba7f8ac 100644
--- a/frontend/features/admin/components/sections/upstreams/upstreams-sheet.tsx
+++ b/frontend/features/admin/components/sections/upstreams/upstreams-sheet.tsx
@@ -1,21 +1,46 @@
"use client";
-import { useState, useEffect } from "react";
-import type {
- AdminLLMCompatible,
- AdminLLMStatus,
- AdminLLMUpstreamAPIKey,
- AdminLLMUpstreamView,
- CreateAdminLLMUpstreamRequest,
- UpdateAdminLLMUpstreamRequest,
-} from "@/features/admin/api/llm.types";
import {
- createAdminLLMUpstream,
- updateAdminLLMUpstream,
-} from "@/features/admin/api";
-import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
+ Braces,
+ ChevronDown,
+ Fingerprint,
+ KeyRound,
+ ListPlus,
+ Network,
+ RotateCcw,
+ ScanSearch,
+ Trash2,
+} from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@@ -30,55 +55,30 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
+import { SpinnerLabel } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
-import {
- Accordion,
- AccordionContent,
- AccordionItem,
- AccordionTrigger,
-} from "@/components/ui/accordion";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { SpinnerLabel } from "@/components/ui/spinner";
-import { Label } from "@/components/ui/label";
-import { toast } from "sonner";
-import { useTranslations } from "next-intl";
-import {
- Braces,
- ChevronDown,
- Fingerprint,
- KeyRound,
- ListPlus,
- Network,
- RotateCcw,
- ScanSearch,
- Trash2,
-} from "lucide-react";
+ createAdminLLMUpstream,
+ updateAdminLLMUpstream,
+} from "@/features/admin/api";
+import type {
+ AdminLLMCompatible,
+ AdminLLMStatus,
+ AdminLLMUpstreamAPIKey,
+ AdminLLMUpstreamView,
+ CreateAdminLLMUpstreamRequest,
+ UpdateAdminLLMUpstreamRequest,
+} from "@/features/admin/api/llm.types";
import { COMPATIBLE_OPTIONS, resolveProtocolLabel } from "@/features/admin/utils/llm-display";
-import { useDialogSnapshot } from "@/shared/hooks/use-dialog-snapshot";
-import { JsonCodeEditor } from "@/shared/components/json-code-editor";
import { useLocalizedErrorMessage } from "@/i18n/use-localized-error";
+import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
+import { JsonCodeEditor } from "@/shared/components/json-code-editor";
+import { useDialogSnapshot } from "@/shared/hooks/use-dialog-snapshot";
const PROTOCOL_DEFAULT_KINDS = [
"chat",
@@ -86,6 +86,7 @@ const PROTOCOL_DEFAULT_KINDS = [
"image_gen",
"image_edit",
"video_gen",
+ "video_extension",
] as const;
const NO_PROTOCOL_DEFAULT = "__system_default__";
@@ -142,6 +143,7 @@ const PROTOCOL_OPTIONS_BY_KIND: Record<(typeof PROTOCOL_DEFAULT_KINDS)[number],
"gemini_interactions",
"xai_video",
],
+ video_extension: ["xai_video_extensions"],
};
const CODE_TEXTAREA_CLASS = "font-mono text-xs placeholder:font-sans placeholder:text-xs";
diff --git a/frontend/features/admin/components/sections/upstreams/upstreams-table.tsx b/frontend/features/admin/components/sections/upstreams/upstreams-table.tsx
index 8dc05c176..24cd82c1d 100644
--- a/frontend/features/admin/components/sections/upstreams/upstreams-table.tsx
+++ b/frontend/features/admin/components/sections/upstreams/upstreams-table.tsx
@@ -40,6 +40,7 @@ const PROTOCOL_DEFAULT_KIND_ORDER = [
"image_gen",
"image_edit",
"video_gen",
+ "video_extension",
];
const PROTOCOL_DEFAULT_KINDS = new Set(PROTOCOL_DEFAULT_KIND_ORDER);
diff --git a/frontend/features/admin/model/billing-settings.ts b/frontend/features/admin/model/billing-settings.ts
index aa7c4f097..b3f3d2271 100644
--- a/frontend/features/admin/model/billing-settings.ts
+++ b/frontend/features/admin/model/billing-settings.ts
@@ -308,7 +308,7 @@ const DEFAULT_IMPORT_MESSAGES: ModelPricingImportMessages = {
duplicateModel: (model) => `${model} appears more than once`,
pricingObject: (model) => `${model} pricing must be an object`,
invalidPricingMode: (model) => `${model}.pricingMode must be token, call, duration, or tiered`,
- durationVideoOnly: (model) => `${model}.pricingMode=duration requires the video_gen model capability`,
+ durationVideoOnly: (model) => `${model}.pricingMode=duration requires a video model capability`,
invalidNumber: (model, field) => `${model}.${field} must be a number greater than or equal to 0`,
invalidTieredPricing: (model, field) => `${model}.${field} must contain a non-empty tiers array`,
invalidTieredPricingJSON: (model) => `${model}.tieredPricingJSON is not valid JSON`,
@@ -570,7 +570,9 @@ export function buildPricingRows(models: AdminLLMModelDTO[], pricingItems: Admin
icon: pricing?.modelIcon || model.icon || "",
pricing,
isFree: pricing?.isFree ?? false,
- supportsVideoGeneration: parseKindsJSON(model.kindsJSON).includes("video_gen"),
+ supportsVideoGeneration: parseKindsJSON(model.kindsJSON).some(
+ (kind) => kind === "video_gen" || kind === "video_extension",
+ ),
};
});
}
diff --git a/frontend/features/admin/model/conversation-settings.ts b/frontend/features/admin/model/conversation-settings.ts
index 0b318c35c..544ae1f2d 100644
--- a/frontend/features/admin/model/conversation-settings.ts
+++ b/frontend/features/admin/model/conversation-settings.ts
@@ -176,6 +176,9 @@ export const DEFAULT_MODEL_OPTION_ALLOWED_PATHS = `{
"aspect_ratio",
"duration",
"resolution"
+ ],
+ "xai_video_extensions": [
+ "duration"
]
}`;
diff --git a/frontend/features/admin/types/llm.ts b/frontend/features/admin/types/llm.ts
index c9ea6583d..0c25974aa 100644
--- a/frontend/features/admin/types/llm.ts
+++ b/frontend/features/admin/types/llm.ts
@@ -36,6 +36,7 @@ export const ADAPTER_LABELS: Record
= {
xai_image: resolveProtocolLabel("xai_image"),
xai_image_edits: resolveProtocolLabel("xai_image_edits"),
xai_video: resolveProtocolLabel("xai_video"),
+ xai_video_extensions: resolveProtocolLabel("xai_video_extensions"),
};
// ---------------------------------------------------------------------------
diff --git a/frontend/features/admin/utils/llm-display.ts b/frontend/features/admin/utils/llm-display.ts
index 4ef328051..bac73e116 100644
--- a/frontend/features/admin/utils/llm-display.ts
+++ b/frontend/features/admin/utils/llm-display.ts
@@ -1,11 +1,12 @@
+import type { LucideIcon } from "lucide-react";
import {
AudioLines,
Bot,
+ Clapperboard,
ImageIcon,
Paintbrush,
Video,
} from "lucide-react";
-import type { LucideIcon } from "lucide-react";
import type { AdminLLMAdapter } from "@/features/admin/api/llm.types";
export const MODEL_KIND_META: Record<
@@ -17,6 +18,7 @@ export const MODEL_KIND_META: Record<
image_gen: { label: "Image generation", shortLabel: "Image generation", icon: ImageIcon },
image_edit: { label: "Image editing", shortLabel: "Image editing", icon: Paintbrush },
video_gen: { label: "Video generation", shortLabel: "Video generation", icon: Video },
+ video_extension: { label: "Video extension", shortLabel: "Video extension", icon: Clapperboard },
};
export const COMPATIBLE_OPTIONS = [
@@ -48,6 +50,7 @@ export const PROTOCOL_OPTIONS: ReadonlyArray = [
{ value: "xai_image", label: "Images Generations (xAI)", kinds: ["image_gen"] },
{ value: "xai_image_edits", label: "Images Edits (xAI)", kinds: ["image_edit"] },
{ value: "xai_video", label: "Video Generations (xAI)", kinds: ["video_gen"] },
+ { value: "xai_video_extensions", label: "Video Extensions (xAI)", kinds: ["video_extension"] },
{ value: "openrouter_chat_completions", label: "Chat Completions (OpenRouter)", kinds: ["chat"] },
{ value: "openrouter_responses", label: "Responses (OpenRouter)", kinds: ["chat"] },
] as const;
@@ -69,6 +72,10 @@ const IMAGE_ROUTE_PROTOCOL_PAIRS: ReadonlyArray = [
+ ["xai_video", "xai_video_extensions"],
+];
+
const LLM_STATUS_LABELS: Record = {
active: "Enabled",
inactive: "Disabled",
@@ -124,8 +131,9 @@ export function isSupportedRouteProtocolSelection(protocols: readonly AdminLLMAd
if (uniqueProtocols.length <= 1) {
return true;
}
- return uniqueProtocols.length === 2 && IMAGE_ROUTE_PROTOCOL_PAIRS.some(([generationProtocol, editProtocol]) =>
- uniqueProtocols.includes(generationProtocol) && uniqueProtocols.includes(editProtocol),
+ return uniqueProtocols.length === 2 && [...IMAGE_ROUTE_PROTOCOL_PAIRS, ...VIDEO_ROUTE_PROTOCOL_PAIRS].some(
+ ([primaryProtocol, secondaryProtocol]) =>
+ uniqueProtocols.includes(primaryProtocol) && uniqueProtocols.includes(secondaryProtocol),
);
}
diff --git a/frontend/features/chat/components/app-chat-area.tsx b/frontend/features/chat/components/app-chat-area.tsx
index f63c831f9..244c16261 100644
--- a/frontend/features/chat/components/app-chat-area.tsx
+++ b/frontend/features/chat/components/app-chat-area.tsx
@@ -1,10 +1,19 @@
"use client";
-import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
+import * as React from "react";
import { toast } from "sonner";
-
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import {
ConversationShareDialog,
sharePatchFromDTO,
@@ -14,53 +23,43 @@ import {
import { ChatArea, ChatAreaLoadError, ChatAreaSkeleton } from "@/features/chat/components/sections/chat-area";
import { ChatArtifactWorkspace } from "@/features/chat/components/sections/chat-artifact";
import { ChatEmptyState } from "@/features/chat/components/sections/chat-empty";
+import { ChatInput } from "@/features/chat/components/sections/chat-input";
+import { ChatScreenshotPreviewDialog } from "@/features/chat/components/sections/chat-screenshot-preview-dialog";
import { useChatSession } from "@/features/chat/context/chat-session-context";
import { useChatArtifacts } from "@/features/chat/hooks/use-chat-artifacts";
import { useChatAttachments } from "@/features/chat/hooks/use-chat-attachments";
-import { useChatComposerState } from "@/features/chat/hooks/use-chat-composer-state";
import { useChatComposerSelection } from "@/features/chat/hooks/use-chat-composer-selection";
-import type { ChatAreaMessage, MessageAttachment } from "@/features/chat/types/messages";
+import { useChatComposerState } from "@/features/chat/hooks/use-chat-composer-state";
+import { useChatData } from "@/features/chat/hooks/use-chat-data";
import { useChatModelOptions } from "@/features/chat/hooks/use-chat-model-options";
import { useChatRuntime } from "@/features/chat/hooks/use-chat-runtime";
-import { useChatViewerProfile } from "@/features/chat/hooks/use-chat-viewer-profile";
import { useChatScreenshot } from "@/features/chat/hooks/use-chat-screenshot";
-import { parseConversationLabelsJSON } from "@/shared/lib/conversation-labels";
+import { useChatViewerProfile } from "@/features/chat/hooks/use-chat-viewer-profile";
import { useChatVisualPrompt } from "@/features/chat/hooks/use-chat-visual-prompt";
-import { ChatInput } from "@/features/chat/components/sections/chat-input";
-import { ChatScreenshotPreviewDialog } from "@/features/chat/components/sections/chat-screenshot-preview-dialog";
-import { resolveChatContentWidthClassName } from "@/shared/model/chat-content-width";
-import { DeleteFilesOption } from "@/shared/components/delete-files-option";
-import { useSettingsChatPreferences } from "@/features/settings/hooks/use-settings-chat-preferences";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@/components/ui/alert-dialog";
+import { useNewConversationDefaults } from "@/features/chat/hooks/use-new-conversation-defaults";
import {
cloneConversationOptions,
isConversationOptionsObject,
sanitizeConversationOptions,
} from "@/features/chat/model/conversation-options";
-import { useChatData } from "@/features/chat/hooks/use-chat-data";
-import { useNewConversationDefaults } from "@/features/chat/hooks/use-new-conversation-defaults";
import { toPendingAttachment } from "@/features/chat/model/message-submit";
+import type { ChatAreaMessage, MessageAttachment } from "@/features/chat/types/messages";
+import { useSettingsChatPreferences } from "@/features/settings/hooks/use-settings-chat-preferences";
+import { cn } from "@/lib/utils";
import { getConversation } from "@/shared/api/conversation";
-import { listAvailableMCPTools } from "@/shared/api/mcp";
-import { getUserSettings, patchUserSettings } from "@/shared/api/user-settings";
-import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
import type { ConversationDTO, ConversationOptions } from "@/shared/api/conversation.types";
import type { FileObjectDTO } from "@/shared/api/file.types";
+import { listAvailableMCPTools } from "@/shared/api/mcp";
import type { MCPToolDTO } from "@/shared/api/mcp.types";
+import { getUserSettings, patchUserSettings } from "@/shared/api/user-settings";
+import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
+import { DeleteFilesOption } from "@/shared/components/delete-files-option";
+import { parseConversationLabelsJSON } from "@/shared/lib/conversation-labels";
import {
hasMultipleImageAttachmentProcessors,
normalizeImageAttachmentProcessorSelection,
} from "@/shared/lib/mcp-tool-selection";
-import { cn } from "@/lib/utils";
+import { resolveChatContentWidthClassName } from "@/shared/model/chat-content-width";
const MODEL_OPTIONS_STORAGE_PREFIX = "deeix-chat:chat-model-options:";
const DEFAULT_MCP_TOOLS_SETTING_KEY = "chat.default_mcp_tool_ids";
@@ -774,6 +773,42 @@ export function AppChatArea() {
],
);
+ const onExtendGeneratedVideoAttachment = React.useCallback(
+ (attachment: MessageAttachment, sourceModelName?: string) => {
+ const normalizedSourceModelName = sourceModelName?.trim() || "";
+ const sourceModel = modelOptions.find(
+ (item) =>
+ item.platformModelName === normalizedSourceModelName &&
+ item.videoExtension?.enabled,
+ );
+ const extensionModel =
+ sourceModel ??
+ (selectedModel?.videoExtension?.enabled ? selectedModel : undefined) ??
+ modelOptions.find((item) => item.videoExtension?.enabled);
+
+ if (!extensionModel) {
+ toast.error(t("submit.mediaMode.blockedDescriptions.video_extension_unsupported"));
+ return;
+ }
+
+ releaseAttachments(attachments);
+ setAttachments([toPendingAttachment(attachment)]);
+ if (extensionModel.platformModelName !== selectedPlatformModelName) {
+ setSelectedPlatformModelName(extensionModel.platformModelName);
+ }
+ },
+ [
+ attachments,
+ modelOptions,
+ releaseAttachments,
+ selectedModel,
+ selectedPlatformModelName,
+ setAttachments,
+ setSelectedPlatformModelName,
+ t,
+ ],
+ );
+
const onAttachExistingFile = React.useCallback(
(file: FileObjectDTO) => {
const alreadyAttached = attachments.some((item) => item.fileID === file.fileID);
@@ -1293,6 +1328,7 @@ export function AppChatArea() {
onModelChange={setSelectedPlatformModelName}
onModelCatalogRefresh={refreshModelCatalogForComposer}
onEditImageAttachment={onEditGeneratedImageAttachment}
+ onExtendVideoAttachment={onExtendGeneratedVideoAttachment}
onOpenCodeArtifact={artifactWorkspace.openArtifact}
onCycleMessageBranch={onCycleMessageBranch}
onToggleStar={onToggleActiveConversationStar}
diff --git a/frontend/features/chat/components/message/message-bot.tsx b/frontend/features/chat/components/message/message-bot.tsx
index 0addff7bf..d4226e683 100644
--- a/frontend/features/chat/components/message/message-bot.tsx
+++ b/frontend/features/chat/components/message/message-bot.tsx
@@ -1,23 +1,9 @@
"use client";
-import * as React from "react";
-import { ChevronDown, CircleAlert, Film } from "lucide-react";
+import { ChevronDown, CircleAlert, Film, GalleryHorizontalEnd } from "lucide-react";
import { useTranslations } from "next-intl";
-
-import { AssistantMessageMeta } from "@/features/chat/components/message/message-meta";
-import { MessageAttachmentRow } from "@/features/chat/components/message/message-attachment";
-import { MessageKnowledgeSources } from "@/features/chat/components/message/message-knowledge-sources";
-import { MessageProcessTrace, MessageTraceEventBlocks } from "@/features/chat/components/message/message-process-trace";
+import * as React from "react";
import { GrainientBackground } from "@/components/reactbits/backgrounds/grainient";
-import type { AssistantReaction } from "@/features/chat/components/message/message-meta";
-import type {
- ChatAreaMessage,
- ChatInlineAlert,
- MessageAttachment,
-} from "@/features/chat/types/messages";
-import { MarkdownImage, type MarkdownArtifactActions } from "@/shared/components/markdown/streamdown-components";
-import { StreamdownRender } from "@/shared/components/markdown/streamdown-render";
-import { PreviewMedia } from "@/shared/components/file-preview/preview-media";
import {
Accordion,
AccordionContent,
@@ -31,20 +17,34 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
-import { cn } from "@/lib/utils";
-import { isUpstreamStreamingDebugBody, summarizeUpstreamError } from "@/features/chat/utils/chat-runtime";
-import { fetchFileContent, type FileContentResult } from "@/shared/api/file";
-import type { PreviewDialogFile } from "@/shared/components/file-preview/preview-dialog";
-import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
-import { useLocalizedErrorMessage } from "@/i18n/use-localized-error";
+import { MessageAttachmentRow } from "@/features/chat/components/message/message-attachment";
+import { MessageKnowledgeSources } from "@/features/chat/components/message/message-knowledge-sources";
+import type { AssistantReaction } from "@/features/chat/components/message/message-meta";
+import { AssistantMessageMeta } from "@/features/chat/components/message/message-meta";
+import { MessageProcessTrace, MessageTraceEventBlocks } from "@/features/chat/components/message/message-process-trace";
import { resolveLeadingImagePreview } from "@/features/chat/model/media-image-preview";
import {
clearLiveUpstreamThinkTrace,
mergeLiveUpstreamThinkTrace,
useLiveUpstreamThinkTrace,
} from "@/features/chat/model/upstream-think-store";
-import type { BillingDisplayCurrency } from "@/shared/lib/billing-display";
+import type {
+ ChatAreaMessage,
+ ChatInlineAlert,
+ MessageAttachment,
+} from "@/features/chat/types/messages";
+import { isUpstreamStreamingDebugBody, summarizeUpstreamError } from "@/features/chat/utils/chat-runtime";
+import { useLocalizedErrorMessage } from "@/i18n/use-localized-error";
+import { cn } from "@/lib/utils";
+import { type FileContentResult, fetchFileContent } from "@/shared/api/file";
+import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
+import type { PreviewDialogFile } from "@/shared/components/file-preview/preview-dialog";
+import { PreviewMedia } from "@/shared/components/file-preview/preview-media";
+import { type MarkdownArtifactActions, MarkdownImage } from "@/shared/components/markdown/streamdown-components";
+import { StreamdownRender } from "@/shared/components/markdown/streamdown-render";
+import { MediaActionBar, MediaActionButton } from "@/shared/components/media-action-bar";
import { useBranding } from "@/shared/config/branding-provider";
+import type { BillingDisplayCurrency } from "@/shared/lib/billing-display";
const EMPTY_TRACE_EVENTS: NonNullable["events"] = [];
@@ -69,6 +69,16 @@ function isVideoAttachment(attachment: MessageAttachment): boolean {
);
}
+function isMP4VideoAttachment(attachment: MessageAttachment): boolean {
+ const mimeType = attachment.mimeType.toLowerCase();
+ const detectedMime = attachment.detectedMime?.toLowerCase() || "";
+ return (
+ mimeType === "video/mp4" ||
+ detectedMime === "video/mp4" ||
+ attachment.fileName.toLowerCase().endsWith(".mp4")
+ );
+}
+
function resolveFileIDFromImageSrc(src: string): string | null {
if (typeof window === "undefined") {
return null;
@@ -146,6 +156,7 @@ type ChatMessageBotProps = {
readOnly?: boolean;
attachmentContentLoader?: (file: PreviewDialogFile) => Promise;
onEditImageAttachment?: (attachment: MessageAttachment, sourceModelName?: string) => void;
+ onExtendVideoAttachment?: (attachment: MessageAttachment, sourceModelName?: string) => void;
artifactActions?: MarkdownArtifactActions;
showBranchNavigator?: boolean;
contentWidthClassName?: string;
@@ -174,6 +185,7 @@ export function ChatMessageBot({
readOnly = false,
attachmentContentLoader,
onEditImageAttachment,
+ onExtendVideoAttachment,
artifactActions,
showBranchNavigator = true,
contentWidthClassName = "max-w-[1080px]",
@@ -239,6 +251,15 @@ export function ChatMessageBot({
: item.attachments ?? [],
[inlineVideoAttachment, item.attachments],
);
+ const extendableVideoAttachment =
+ inlineVideoAttachment && isMP4VideoAttachment(inlineVideoAttachment)
+ ? inlineVideoAttachment
+ : null;
+ const onExtendVideo = React.useCallback(() => {
+ if (extendableVideoAttachment) {
+ onExtendVideoAttachment?.(extendableVideoAttachment, item.platformModelName);
+ }
+ }, [extendableVideoAttachment, item.platformModelName, onExtendVideoAttachment]);
const hideGeneratedVideoMarkdown = inlineVideoAttachment
? isGeneratedVideoMarkdownContent(item.content, item.attachments ?? [])
: false;
@@ -389,7 +410,15 @@ export function ChatMessageBot({
{inlineVideoAttachment ? (
-
+
) : null}
{item.inlineAlert ? (
@@ -688,24 +717,42 @@ type InlineVideoPreviewState =
| { status: "error"; message: string }
| { status: "ready"; source: string; contentType: string };
+function InlineVideoLoadingPlaceholder() {
+ return (
+
+
+
+ );
+}
+
function MessageInlineVideoPreview({
attachment,
loadContent,
+ onExtend,
}: {
attachment: MessageAttachment;
loadContent?: (file: PreviewDialogFile) => Promise;
+ onExtend?: () => void;
}) {
const tPreview = useTranslations("files.previewDialog");
const tMessages = useTranslations("chat.messages");
const resolveErrorMessage = useLocalizedErrorMessage();
const objectURLRef = React.useRef(null);
- const [state, setState] = React.useState({ status: "loading" });
const fileID = attachment.fileID;
const fileName = attachment.fileName;
const mimeType = attachment.mimeType;
const detectedMime = attachment.detectedMime;
const previewURL = attachment.previewURL;
const sizeBytes = attachment.sizeBytes;
+ const [state, setState] = React.useState(() =>
+ previewURL
+ ? {
+ status: "ready",
+ source: previewURL,
+ contentType: detectedMime || mimeType,
+ }
+ : { status: "loading" },
+ );
const revokeObjectURL = React.useCallback(() => {
if (!objectURLRef.current) {
return;
@@ -787,7 +834,7 @@ function MessageInlineVideoPreview({
]);
if (state.status === "loading") {
- return ;
+ return ;
}
if (state.status === "error") {
@@ -800,7 +847,7 @@ function MessageInlineVideoPreview({
}
return (
-
+
+ {onExtend ? (
+
+
+
+
+
+ ) : null}
);
}
diff --git a/frontend/features/chat/components/message/message-meta.tsx b/frontend/features/chat/components/message/message-meta.tsx
index e185f19b0..2ebab062c 100644
--- a/frontend/features/chat/components/message/message-meta.tsx
+++ b/frontend/features/chat/components/message/message-meta.tsx
@@ -1,41 +1,45 @@
"use client";
-import * as React from "react";
import {
ArrowDownToLine,
ArrowUpFromLine,
Brain,
+ CircleDollarSign,
ClockArrowUp,
ClockCheck,
- CircleDollarSign,
- TicketSlash,
+ Cpu,
DatabaseSearch,
DatabaseZap,
- Cpu,
FilePenLine,
Forward,
+ TicketSlash,
} from "lucide-react";
import { useTranslations } from "next-intl";
+import * as React from "react";
import { toast } from "sonner";
import { Brush } from "@/components/animate-ui/icons/brush";
+import { Check } from "@/components/animate-ui/icons/check";
import { ChevronLeft } from "@/components/animate-ui/icons/chevron-left";
import { ChevronRight } from "@/components/animate-ui/icons/chevron-right";
-import { Check } from "@/components/animate-ui/icons/check";
import { Copy } from "@/components/animate-ui/icons/copy";
import { GitFork } from "@/components/animate-ui/icons/git-fork";
import { Heart } from "@/components/animate-ui/icons/heart";
import { RotateCcw } from "@/components/animate-ui/icons/rotate-ccw";
import { ThumbsDown } from "@/components/animate-ui/icons/thumbs-down";
import { ThumbsUp } from "@/components/animate-ui/icons/thumbs-up";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
-import { upsertUserMemory } from "@/shared/api/memory";
-import { useLocalizedErrorMessage } from "@/i18n/use-localized-error";
import { resolvePersistedPublicID } from "@/features/chat/model/message-submit";
+import type { ChatBillingCost, ChatMessageBranchNavigator } from "@/features/chat/types/messages";
+import { useLocalizedErrorMessage } from "@/i18n/use-localized-error";
+import { cn } from "@/lib/utils";
+import { upsertUserMemory } from "@/shared/api/memory";
+import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
+import { usePointerInteraction } from "@/shared/hooks/use-pointer-interaction";
+import type { BillingDisplayCurrency, BillingDisplayLabels, BillingDisplayOptions } from "@/shared/lib/billing-display";
import {
billingRateMultiplierNote,
cacheWriteBillingLabel,
@@ -44,10 +48,6 @@ import {
formatBillingDisplayPreciseAmountFromUSD,
formatBillingDisplayUnitPriceFromUSD,
} from "@/shared/lib/billing-display";
-import type { BillingDisplayCurrency, BillingDisplayLabels, BillingDisplayOptions } from "@/shared/lib/billing-display";
-import type { ChatBillingCost, ChatMessageBranchNavigator } from "@/features/chat/types/messages";
-import { usePointerInteraction } from "@/shared/hooks/use-pointer-interaction";
-import { cn } from "@/lib/utils";
export type ChatMetaMessage = {
publicID: string;
diff --git a/frontend/features/chat/components/sections/chat-area.tsx b/frontend/features/chat/components/sections/chat-area.tsx
index ca0e1a061..5f0778ef2 100644
--- a/frontend/features/chat/components/sections/chat-area.tsx
+++ b/frontend/features/chat/components/sections/chat-area.tsx
@@ -1,31 +1,9 @@
"use client";
-import * as React from "react";
import { ArrowDownToLine, Check } from "lucide-react";
import { useTranslations } from "next-intl";
-
-import { ChatLabel } from "@/features/chat/components/sections/chat-label";
-import { useChatMessageFeedback } from "@/features/chat/hooks/use-chat-message-feedback";
-import {
- AssistantMessageSkeleton,
- ChatInlineAlertCard,
- ChatMessageBot,
-} from "@/features/chat/components/message/message-bot";
-import { areChatAreaMessagesRenderEqual } from "@/features/chat/model/chat-message-render";
-import { type AssistantReaction } from "@/features/chat/components/message/message-meta";
-import type { ChatAreaMessage, MessageAttachment } from "@/features/chat/types/messages";
-import { ChatMessageUser } from "@/features/chat/components/message/message-user";
-import { StreamdownRender } from "@/shared/components/markdown/streamdown-render";
-import type { OpenCodeArtifactInput } from "@/features/chat/model/chat-artifacts";
+import * as React from "react";
import { CenteredEmptyState } from "@/components/ui/empty-state";
-import { Skeleton } from "@/components/ui/skeleton";
-import { ConversationShareExportIconDropdown } from "@/shared/components/conversation-share-export-menu";
-import { ChatScreenshotSelectionBar } from "@/features/chat/components/sections/chat-screenshot-selection-bar";
-import { useCopyAction } from "@/shared/components/copy-action";
-import type { ChatModelOption } from "@/features/chat/types/chat-runtime";
-import type { BillingDisplayCurrency } from "@/shared/lib/billing-display";
-import type { FileContentResult } from "@/shared/api/file";
-import type { PreviewDialogFile } from "@/shared/components/file-preview/preview-dialog";
import {
MessageScroller,
MessageScrollerButton,
@@ -35,15 +13,36 @@ import {
MessageScrollerViewport,
useMessageScroller,
} from "@/components/ui/message-scroller";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ AssistantMessageSkeleton,
+ ChatInlineAlertCard,
+ ChatMessageBot,
+} from "@/features/chat/components/message/message-bot";
+import { type AssistantReaction } from "@/features/chat/components/message/message-meta";
+import { ChatMessageUser } from "@/features/chat/components/message/message-user";
+import { ChatLabel } from "@/features/chat/components/sections/chat-label";
import {
ChatMessagePositionRail,
chatMessageScrollerID,
} from "@/features/chat/components/sections/chat-message-position-rail";
import { ChatResponseOutlineRail } from "@/features/chat/components/sections/chat-response-outline-rail";
+import { ChatScreenshotSelectionBar } from "@/features/chat/components/sections/chat-screenshot-selection-bar";
+import { useChatMessageFeedback } from "@/features/chat/hooks/use-chat-message-feedback";
+import type { OpenCodeArtifactInput } from "@/features/chat/model/chat-artifacts";
+import { areChatAreaMessagesRenderEqual } from "@/features/chat/model/chat-message-render";
+import type { ChatModelOption } from "@/features/chat/types/chat-runtime";
+import type { ChatAreaMessage, MessageAttachment } from "@/features/chat/types/messages";
import { cn } from "@/lib/utils";
+import type { FileContentResult } from "@/shared/api/file";
import { AppLogo, DeeixLogo } from "@/shared/components/app-logo";
-import { useBranding } from "@/shared/config/branding-provider";
+import { ConversationShareExportIconDropdown } from "@/shared/components/conversation-share-export-menu";
+import { useCopyAction } from "@/shared/components/copy-action";
+import type { PreviewDialogFile } from "@/shared/components/file-preview/preview-dialog";
+import { StreamdownRender } from "@/shared/components/markdown/streamdown-render";
import { PoweredByDeeix } from "@/shared/components/powered-by-deeix";
+import { useBranding } from "@/shared/config/branding-provider";
+import type { BillingDisplayCurrency } from "@/shared/lib/billing-display";
function ScrollToPendingUser({ scrollKey }: { scrollKey: string }) {
const handledScrollKeyRef = React.useRef("");
@@ -122,6 +121,7 @@ type ChatAreaProps = {
onModelCatalogRefresh?: () => void | Promise
;
attachmentContentLoader?: (file: PreviewDialogFile) => Promise;
onEditImageAttachment?: (attachment: MessageAttachment, sourceModelName?: string) => void;
+ onExtendVideoAttachment?: (attachment: MessageAttachment, sourceModelName?: string) => void;
onOpenCodeArtifact?: (message: ChatAreaMessage, artifact: OpenCodeArtifactInput) => void;
onCycleMessageBranch: (parentPublicID: string | null, direction: "previous" | "next") => void;
onToggleStar?: () => void | Promise;
@@ -286,6 +286,7 @@ const ChatMessageRow = React.memo(function ChatMessageRow({
onModelCatalogRefresh,
attachmentContentLoader,
onEditImageAttachment,
+ onExtendVideoAttachment,
onCycleMessageBranch,
onReactAssistantMessage,
onOpenCodeArtifact,
@@ -316,6 +317,7 @@ const ChatMessageRow = React.memo(function ChatMessageRow({
onModelCatalogRefresh?: () => void | Promise;
attachmentContentLoader?: (file: PreviewDialogFile) => Promise;
onEditImageAttachment?: (attachment: MessageAttachment, sourceModelName?: string) => void;
+ onExtendVideoAttachment?: (attachment: MessageAttachment, sourceModelName?: string) => void;
onCycleMessageBranch: (parentPublicID: string | null, direction: "previous" | "next") => void;
onReactAssistantMessage: (publicID: string, reaction: AssistantReaction) => void;
onOpenCodeArtifact?: (message: ChatAreaMessage, artifact: OpenCodeArtifactInput) => void;
@@ -358,6 +360,18 @@ const ChatMessageRow = React.memo(function ChatMessageRow({
: undefined,
[isAssistant, item, onOpenCodeArtifact],
);
+ const sourceSupportsVideoExtension = React.useMemo(
+ () =>
+ Boolean(
+ item.platformModelName &&
+ modelOptions.some(
+ (model) =>
+ model.platformModelName === item.platformModelName &&
+ model.videoExtension?.enabled,
+ ),
+ ),
+ [item.platformModelName, modelOptions],
+ );
const copyKey = item.publicID || item.key;
const onCopy = React.useCallback(async () => {
@@ -400,6 +414,9 @@ const ChatMessageRow = React.memo(function ChatMessageRow({
copySucceeded={isCopied(copyKey)}
attachmentContentLoader={attachmentContentLoader}
onEditImageAttachment={onEditImageAttachment}
+ onExtendVideoAttachment={
+ sourceSupportsVideoExtension ? onExtendVideoAttachment : undefined
+ }
artifactActions={artifactActions}
markdownRender={markdownRender}
showModelInfo={showModelInfo}
@@ -447,6 +464,7 @@ const ChatMessageRow = React.memo(function ChatMessageRow({
previous.onModelCatalogRefresh === next.onModelCatalogRefresh &&
previous.attachmentContentLoader === next.attachmentContentLoader &&
previous.onEditImageAttachment === next.onEditImageAttachment &&
+ previous.onExtendVideoAttachment === next.onExtendVideoAttachment &&
previous.onOpenCodeArtifact === next.onOpenCodeArtifact &&
areChatAreaMessagesRenderEqual(previous.item, next.item)
));
@@ -471,6 +489,7 @@ export function ChatArea({
onModelCatalogRefresh,
attachmentContentLoader,
onEditImageAttachment,
+ onExtendVideoAttachment,
onOpenCodeArtifact,
onCycleMessageBranch,
onToggleStar,
@@ -509,9 +528,13 @@ export function ChatArea({
const stableOnEditImageAttachment = useStableEvent((attachment: MessageAttachment, sourceModelName?: string) => {
onEditImageAttachment?.(attachment, sourceModelName);
});
+ const stableOnExtendVideoAttachment = useStableEvent((attachment: MessageAttachment, sourceModelName?: string) => {
+ onExtendVideoAttachment?.(attachment, sourceModelName);
+ });
const stableOnCycleMessageBranch = useStableEvent(onCycleMessageBranch);
const stableOnReactAssistantMessage = useStableEvent(onReactAssistantMessage);
const editImageAttachmentHandler = onEditImageAttachment ? stableOnEditImageAttachment : undefined;
+ const extendVideoAttachmentHandler = onExtendVideoAttachment ? stableOnExtendVideoAttachment : undefined;
const shareLabel = shareActive ? t("manageShare") : t("shareConversation");
const shareExportLabel = t("labelMenu.shareAndExport");
const tScreenshot = useTranslations("chat.screenshot");
@@ -643,6 +666,7 @@ export function ChatArea({
onModelCatalogRefresh={onModelCatalogRefresh ? stableOnModelCatalogRefresh : undefined}
attachmentContentLoader={attachmentContentLoader}
onEditImageAttachment={editImageAttachmentHandler}
+ onExtendVideoAttachment={extendVideoAttachmentHandler}
onCycleMessageBranch={stableOnCycleMessageBranch}
onReactAssistantMessage={stableOnReactAssistantMessage}
onOpenCodeArtifact={onOpenCodeArtifact}
diff --git a/frontend/features/chat/components/sections/chat-input.tsx b/frontend/features/chat/components/sections/chat-input.tsx
index 3370496d3..b9c15cdee 100644
--- a/frontend/features/chat/components/sections/chat-input.tsx
+++ b/frontend/features/chat/components/sections/chat-input.tsx
@@ -1,46 +1,19 @@
"use client";
-import * as React from "react";
-import dynamic from "next/dynamic";
import { Box, CornerDownRight, Eye, EyeOff, Film, Image, ImageOff, ImagePlus, LoaderCircle, PencilLine, Trash2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
+import dynamic from "next/dynamic";
import { useLocale, useTranslations } from "next-intl";
+import * as React from "react";
import { toast } from "sonner";
import { AudioLines } from "@/components/animate-ui/icons/audio-lines";
import { Blocks } from "@/components/animate-ui/icons/blocks";
+import { Crop } from "@/components/animate-ui/icons/crop";
+import { Link as LinkIcon } from "@/components/animate-ui/icons/link";
import { Pause } from "@/components/animate-ui/icons/pause";
import { Send } from "@/components/animate-ui/icons/send";
-import { Link as LinkIcon } from "@/components/animate-ui/icons/link";
-import { Crop } from "@/components/animate-ui/icons/crop";
import { X as XIcon } from "@/components/animate-ui/icons/x";
-import { PlusIcon } from "@/components/ui/plus";
-import type {
- ChatModelOption,
- PendingAttachment,
- UploadingAttachment,
-} from "@/features/chat/types/chat-runtime";
-import {
- formatClipboardMarkdownPaste,
- resolveClipboardMarkdownPaste,
-} from "@/features/chat/utils/markdown-paste";
-import {
- useChatSpeechInput,
- type SpeechInputErrorCode,
-} from "@/features/chat/hooks/use-chat-speech-input";
-import { useMarkdownPreviewSync } from "@/features/chat/hooks/use-markdown-preview-sync";
-import {
- useChatMentionMenu,
- type ChatMentionMenuKind,
-} from "@/features/chat/hooks/use-chat-mention-menu";
-import { ChatMentionMenuPortal } from "@/features/chat/components/shared/chat-mention-menu";
-import { ChatMCP } from "@/features/chat/components/sections/chat-mcp";
-import { ChatKnowledgeBases } from "@/features/chat/components/sections/chat-knowledge-bases";
-import { ChatModelPicker } from "@/features/chat/components/sections/chat-model-picker";
-import { ChatModelConfig } from "@/features/chat/components/sections/chat-model-config";
-import { formatBytes, resolveFileExtension, resolveFileIcon } from "@/shared/lib/file-display";
-import type { ChatSubmitDecision } from "@/features/chat/model/chat-task";
-import { isMediaSubmitTask, resolveChatSubmitDecision } from "@/features/chat/model/chat-task";
import {
Attachment,
AttachmentAction,
@@ -64,19 +37,46 @@ import {
InputGroupButton,
InputGroupTextarea,
} from "@/components/ui/input-group";
+import { PlusIcon } from "@/components/ui/plus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { resolveFileProcessingBadge } from "@/shared/lib/file-processing";
-import { useDialogSnapshot } from "@/shared/hooks/use-dialog-snapshot";
-import { StreamdownRender } from "@/shared/components/markdown/streamdown-render";
+import { ChatKnowledgeBases } from "@/features/chat/components/sections/chat-knowledge-bases";
+import { ChatMCP } from "@/features/chat/components/sections/chat-mcp";
+import { ChatModelConfig } from "@/features/chat/components/sections/chat-model-config";
+import { ChatModelPicker } from "@/features/chat/components/sections/chat-model-picker";
+import { ChatMentionMenuPortal } from "@/features/chat/components/shared/chat-mention-menu";
+import {
+ type ChatMentionMenuKind,
+ useChatMentionMenu,
+} from "@/features/chat/hooks/use-chat-mention-menu";
+import {
+ type SpeechInputErrorCode,
+ useChatSpeechInput,
+} from "@/features/chat/hooks/use-chat-speech-input";
+import { useMarkdownPreviewSync } from "@/features/chat/hooks/use-markdown-preview-sync";
+import type { ChatSubmitDecision } from "@/features/chat/model/chat-task";
+import { isMediaSubmitTask, resolveChatSubmitDecision } from "@/features/chat/model/chat-task";
+import type {
+ ChatModelOption,
+ PendingAttachment,
+ UploadingAttachment,
+} from "@/features/chat/types/chat-runtime";
+import {
+ formatClipboardMarkdownPaste,
+ resolveClipboardMarkdownPaste,
+} from "@/features/chat/utils/markdown-paste";
+import type { SendShortcut } from "@/features/settings/types/settings";
import { cn } from "@/lib/utils";
import type { ConversationOptions } from "@/shared/api/conversation.types";
import type { FileObjectDTO } from "@/shared/api/file.types";
import type { MCPToolDTO } from "@/shared/api/mcp.types";
import type { SkillSummaryDTO } from "@/shared/api/skills.types";
+import { StreamdownRender } from "@/shared/components/markdown/streamdown-render";
+import { useDialogSnapshot } from "@/shared/hooks/use-dialog-snapshot";
+import type { BillingDisplayCurrency } from "@/shared/lib/billing-display";
+import { formatBytes, resolveFileExtension, resolveFileIcon } from "@/shared/lib/file-display";
+import { resolveFileProcessingBadge } from "@/shared/lib/file-processing";
import type { ModelOptionPolicy } from "@/shared/lib/model-option-policy";
-import type { SendShortcut } from "@/features/settings/types/settings";
import { isSendShortcutEvent } from "@/shared/lib/platform-shortcuts";
-import type { BillingDisplayCurrency } from "@/shared/lib/billing-display";
const FilePreviewDialog = dynamic(
() => import("@/shared/components/file-preview/preview-dialog").then((module) => module.FilePreviewDialog),
@@ -202,6 +202,17 @@ function resolveComposerModeIndicator(
tone: "default",
};
}
+ if (decision.task === "video_extension") {
+ return {
+ label: t("mediaMode.videoExtension"),
+ intro: t("mediaMode.videoExtensionIntro"),
+ description: decision.blockedReason
+ ? t(`mediaMode.blockedDescriptions.${decision.blockedReason}`)
+ : t("mediaMode.videoExtensionDescription"),
+ icon: Film,
+ tone: decision.blockedReason ? "warning" : "default",
+ };
+ }
return null;
}
@@ -418,6 +429,17 @@ function ChatInputComponent({
const isMediaMode = isMediaSubmitTask(submitTask);
const composerModeIndicator = resolveComposerModeIndicator(submitDecision, tComposer);
const ComposerModeIcon = composerModeIndicator?.icon;
+ const taskOptionConfig = submitTask === "video_extension" ? selectedModel?.videoExtension : null;
+ const modelConfigOptions = React.useMemo(() => {
+ if (!taskOptionConfig) {
+ return options;
+ }
+ const duration = Number(options.duration);
+ return {
+ ...options,
+ duration: Number.isInteger(duration) && duration >= 2 && duration <= 10 ? duration : 6,
+ };
+ }, [options, taskOptionConfig]);
const modelOptionPolicyDisabled = modelOptionPolicy?.mode?.trim() === "disabled";
const showMCPToolsButton = availableTools.length > 0 && !isMediaMode;
const showHTMLVisualPromptButton = !isMediaMode;
@@ -950,10 +972,10 @@ function ChatInputComponent({
{!modelOptionPolicyDisabled ? (
= {
xai_image: "Images Generations",
xai_image_edits: "Images Edits",
xai_video: "Video Generations",
+ xai_video_extensions: "Video Extensions",
xai_responses: "xAI Responses",
};
diff --git a/frontend/features/chat/hooks/use-chat-message-submit.ts b/frontend/features/chat/hooks/use-chat-message-submit.ts
index 45f11c417..4f9bcf1be 100644
--- a/frontend/features/chat/hooks/use-chat-message-submit.ts
+++ b/frontend/features/chat/hooks/use-chat-message-submit.ts
@@ -44,6 +44,7 @@ import {
streamMessage as streamConversationMessage,
streamImageEdit,
streamImageGeneration,
+ streamVideoExtension,
streamVideoGeneration,
updateMessage,
} from "@/shared/api/conversation";
@@ -51,6 +52,7 @@ import type {
ConversationDTO,
ConversationOptions,
MediaImageRequest,
+ MediaVideoExtensionRequest,
MediaVideoRequest,
MessageDTO,
SendMessageRequest,
@@ -96,6 +98,13 @@ function resolveImageLoadingAspectRatio(options: ConversationOptions): ImageLoad
return "square";
}
+function resolveVideoExtensionOptions(options: ConversationOptions): ConversationOptions {
+ const duration = Number(options.duration);
+ return {
+ duration: Number.isInteger(duration) && duration >= 2 && duration <= 10 ? duration : 6,
+ };
+}
+
function streamEventErrorToApiError(
event: Extract,
fallback: string,
@@ -840,7 +849,7 @@ export function useChatMessageSubmit({
? resolveImageLoadingAspectRatio(sanitizedOptions)
: undefined;
const assistantContentType =
- submitTask === "chat" ? "markdown" : submitTask === "video_generation" ? "video" : "image";
+ submitTask === "chat" ? "markdown" : submitTask === "video_generation" || submitTask === "video_extension" ? "video" : "image";
let targetConversationID = queuedSubmission?.conversationPublicID ?? conversationIDRef.current;
let targetConversation = queuedSubmission?.conversation ?? activeConversationRef.current;
let metadataRefreshInFlight = false;
@@ -1033,9 +1042,12 @@ export function useChatMessageSubmit({
}
touchByPublicID(targetConversationID, { title: optimisticTitle });
}
+ const effectiveOptions = submitTask === "video_extension"
+ ? resolveVideoExtensionOptions(sanitizedOptions)
+ : sanitizedOptions;
const commonStreamPayload = {
model: requestPlatformModelName,
- options: Object.keys(sanitizedOptions).length > 0 ? sanitizedOptions : undefined,
+ options: Object.keys(effectiveOptions).length > 0 ? effectiveOptions : undefined,
clientRunID: clientRunID,
fileIDs: effectiveAttachments.length > 0 ? effectiveAttachments.map((item) => item.fileID) : undefined,
parentMessagePublicID: resolvedParentPublicID || undefined,
@@ -1180,6 +1192,22 @@ export function useChatMessageSubmit({
prompt: payloadContent,
};
completed = await streamVideoGeneration(token, targetConversationID, mediaPayload, streamOptions);
+ } else if (submitTask === "video_extension") {
+ const sourceVideoFileID = effectiveAttachments[0]?.fileID;
+ if (!sourceVideoFileID) {
+ throw new Error("video extension source is missing");
+ }
+ const mediaPayload: MediaVideoExtensionRequest = {
+ model: commonStreamPayload.model,
+ options: commonStreamPayload.options,
+ clientRunID: commonStreamPayload.clientRunID,
+ parentMessagePublicID: commonStreamPayload.parentMessagePublicID,
+ sourceMessagePublicID: commonStreamPayload.sourceMessagePublicID,
+ branchReason: commonStreamPayload.branchReason,
+ prompt: payloadContent,
+ sourceVideoFileID,
+ };
+ completed = await streamVideoExtension(token, targetConversationID, mediaPayload, streamOptions);
} else {
const mediaPayload: MediaImageRequest = {
...commonStreamPayload,
diff --git a/frontend/features/chat/hooks/use-chat-model-options.ts b/frontend/features/chat/hooks/use-chat-model-options.ts
index 71b3247df..ab2aeaea2 100644
--- a/frontend/features/chat/hooks/use-chat-model-options.ts
+++ b/frontend/features/chat/hooks/use-chat-model-options.ts
@@ -1,39 +1,38 @@
"use client";
-import * as React from "react";
import { useTranslations } from "next-intl";
-
+import * as React from "react";
+import { sanitizeConversationOptions } from "@/features/chat/model/conversation-options";
import type {
ChatModelOption,
ModelOptionControl,
ModelOptionControlType,
} from "@/features/chat/types/chat-runtime";
-import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
-import { parseProtocolsJSON } from "@/shared/lib/model-protocols";
-import { sanitizeConversationOptions } from "@/features/chat/model/conversation-options";
-import {
- DEFAULT_CHAT_CONTENT_WIDTH,
- parseChatContentWidth,
- type ChatContentWidth,
-} from "@/shared/model/chat-content-width";
+import { USER_SETTINGS_UPDATED_EVENT } from "@/features/settings/events/user-settings-events";
+import type { SendShortcut } from "@/features/settings/types/settings";
+import { parseSendShortcut } from "@/features/settings/utils/chat-settings";
+import { getBillingConfig } from "@/shared/api/billing";
import { listConversationRuns } from "@/shared/api/conversation";
+import type { ConversationOptions } from "@/shared/api/conversation.types";
import { listPublicModels } from "@/shared/api/model";
-import { getBillingConfig } from "@/shared/api/billing";
+import type { PublicModelDTO } from "@/shared/api/model.types";
import { getMCPPolicy, getModelOptionPolicy } from "@/shared/api/settings";
import { getUserSettings } from "@/shared/api/user-settings";
-import type { PublicModelDTO } from "@/shared/api/model.types";
-import type { ModelNativeToolConfig, ModelOptionPolicy } from "@/shared/lib/model-option-policy";
-import { nativeToolDefinitionVariantsFromConfig, nativeToolPayloadSignature } from "@/shared/lib/native-tool-payload";
-import { parseKindsJSON } from "@/shared/model/llm-schema";
-import { resolveConversationDefaultModel } from "@/shared/model/conversation-default-model";
-import type { ConversationOptions } from "@/shared/api/conversation.types";
-import type { SendShortcut } from "@/features/settings/types/settings";
-import { parseSendShortcut } from "@/features/settings/utils/chat-settings";
-import { USER_SETTINGS_UPDATED_EVENT } from "@/features/settings/events/user-settings-events";
+import { resolveAccessToken } from "@/shared/auth/resolve-access-token";
import {
- normalizeBillingDisplayCurrency,
type BillingDisplayCurrency,
+ normalizeBillingDisplayCurrency,
} from "@/shared/lib/billing-display";
+import type { ModelNativeToolConfig, ModelOptionPolicy } from "@/shared/lib/model-option-policy";
+import { parseProtocolsJSON } from "@/shared/lib/model-protocols";
+import { nativeToolDefinitionVariantsFromConfig, nativeToolPayloadSignature } from "@/shared/lib/native-tool-payload";
+import {
+ type ChatContentWidth,
+ DEFAULT_CHAT_CONTENT_WIDTH,
+ parseChatContentWidth,
+} from "@/shared/model/chat-content-width";
+import { resolveConversationDefaultModel } from "@/shared/model/conversation-default-model";
+import { parseKindsJSON } from "@/shared/model/llm-schema";
type ModelCatalogRefreshResult = {
models: PublicModelDTO[];
@@ -308,6 +307,39 @@ function resolveOptionControls(raw: string): ModelOptionControl[] {
return controls.filter((item, index) => controls.findIndex((candidate) => candidate.path === item.path) === index);
}
+function resolveVideoExtensionConfig(raw: string, protocols: string[]): ChatModelOption["videoExtension"] {
+ const parsed = parseJSONObject(raw);
+ const mediaTasks = parsed?.mediaTasks;
+ const taskSource = mediaTasks && typeof mediaTasks === "object" && !Array.isArray(mediaTasks)
+ ? (mediaTasks as Record).video_extension
+ : undefined;
+ const task = taskSource && typeof taskSource === "object" && !Array.isArray(taskSource)
+ ? (taskSource as Record)
+ : null;
+ const protocolSupported = protocols.includes("xai_video_extensions");
+ if (!protocolSupported || task?.enabled === false) {
+ return null;
+ }
+ const defaultOptions = task?.defaultOptions && typeof task.defaultOptions === "object" && !Array.isArray(task.defaultOptions)
+ ? sanitizeConversationOptions(task.defaultOptions as ConversationOptions)
+ : { duration: 6 };
+ const rawControls = Array.isArray(task?.optionControls) ? task.optionControls : [{ path: "duration", type: "select", label: "Duration", description: "2–10 seconds", options: ["2", "3", "4", "5", "6", "7", "8", "9", "10"] }];
+ const controls = rawControls.flatMap((item): ModelOptionControl[] => {
+ if (!item || typeof item !== "object" || Array.isArray(item)) return [];
+ const source = item as Record;
+ const path = normalizeOptionControlPath(source.path);
+ if (path !== "duration") return [];
+ return [{
+ path,
+ type: normalizeOptionControlType(source.type) ?? "select",
+ label: normalizeOptionControlString(source.label),
+ description: normalizeOptionControlString(source.description),
+ options: normalizeOptionControlOptions(source.options) ?? ["2", "3", "4", "5", "6", "7", "8", "9", "10"],
+ }];
+ });
+ return { enabled: true, defaultOptions, optionControls: controls };
+}
+
function resolveNativeToolKeys(raw: string): string[] {
const parsed = parseJSONObject(raw);
const rawKeys = parsed?.nativeToolKeys;
@@ -354,6 +386,7 @@ function toChatModelOption(
nativeToolKeys: resolveNativeToolKeys(item.capabilitiesJSON),
nativeTools,
pricing: item.pricing,
+ videoExtension: resolveVideoExtensionConfig(item.capabilitiesJSON, protocols),
};
}
diff --git a/frontend/features/chat/model/chat-task.ts b/frontend/features/chat/model/chat-task.ts
index cf48c56bf..773e85d9f 100644
--- a/frontend/features/chat/model/chat-task.ts
+++ b/frontend/features/chat/model/chat-task.ts
@@ -1,7 +1,7 @@
import type { ChatModelOption, PendingAttachment } from "@/features/chat/types/chat-runtime";
import type { ConversationOptions } from "@/shared/api/conversation.types";
-export type ChatSubmitTask = "chat" | "image_generation" | "image_edit" | "video_generation";
+export type ChatSubmitTask = "chat" | "image_generation" | "image_edit" | "video_generation" | "video_extension";
export type ChatSubmitBlockReason =
| "image_edit_input_required"
| "image_edit_unsupported"
@@ -9,6 +9,8 @@ export type ChatSubmitBlockReason =
| "image_task_rejects_non_image_attachments"
| "video_generation_too_many_images"
| "video_task_rejects_non_image_attachments"
+ | "video_extension_requires_single_mp4"
+ | "video_extension_unsupported"
| "model_task_unsupported";
export type ChatSubmitDecision = {
@@ -21,12 +23,21 @@ export type ChatSubmitDecision = {
supportsImageGeneration: boolean;
supportsImageEdit: boolean;
supportsVideoGeneration: boolean;
+ supportsVideoExtension: boolean;
};
function isImageAttachment(item: PendingAttachment): boolean {
return item.fileCategory === "image" || item.mimeType.toLowerCase().startsWith("image/");
}
+function isMP4Attachment(item: PendingAttachment): boolean {
+ return item.mimeType.toLowerCase() === "video/mp4" || item.detectedMime?.toLowerCase() === "video/mp4";
+}
+
+function isVideoAttachment(item: PendingAttachment): boolean {
+ return item.mimeType.toLowerCase().startsWith("video/") || item.detectedMime?.toLowerCase().startsWith("video/") === true;
+}
+
function buildDecision(
task: ChatSubmitTask,
blockedReason: ChatSubmitBlockReason | null,
@@ -75,11 +86,14 @@ export function resolveChatSubmitDecision(
const kinds = new Set(model?.kinds ?? []);
const attachmentCount = attachments.length;
const imageAttachmentCount = attachments.filter(isImageAttachment).length;
+ const mp4AttachmentCount = attachments.filter(isMP4Attachment).length;
+ const videoAttachmentCount = attachments.filter(isVideoAttachment).length;
const nonImageAttachmentCount = attachmentCount - imageAttachmentCount;
const supportsChat = kinds.size === 0 || kinds.has("chat") || kinds.has("audio");
const supportsImageGeneration = kinds.has("image_gen");
const supportsImageEdit = kinds.has("image_edit");
const supportsVideoGeneration = kinds.has("video_gen");
+ const supportsVideoExtension = model?.videoExtension?.enabled === true;
const baseDecision = {
attachmentCount,
imageAttachmentCount,
@@ -88,9 +102,20 @@ export function resolveChatSubmitDecision(
supportsImageGeneration,
supportsImageEdit,
supportsVideoGeneration,
+ supportsVideoExtension,
};
const requestedType = requestedResponseType(options);
+ if (videoAttachmentCount > 0) {
+ if (attachmentCount !== 1 || mp4AttachmentCount !== 1) {
+ return buildDecision("video_extension", "video_extension_requires_single_mp4", baseDecision);
+ }
+ if (!supportsVideoExtension) {
+ return buildDecision("video_extension", "video_extension_unsupported", baseDecision);
+ }
+ return buildDecision("video_extension", null, baseDecision);
+ }
+
if (
nonImageAttachmentCount > 0 &&
(supportsImageGeneration || supportsImageEdit || supportsVideoGeneration) &&
@@ -169,5 +194,5 @@ export function resolveChatSubmitDecision(
}
export function isMediaSubmitTask(task: ChatSubmitTask): boolean {
- return task === "image_generation" || task === "image_edit" || task === "video_generation";
+ return task === "image_generation" || task === "image_edit" || task === "video_generation" || task === "video_extension";
}
diff --git a/frontend/features/chat/types/chat-runtime.ts b/frontend/features/chat/types/chat-runtime.ts
index 5a9bde6fb..aee9820c1 100644
--- a/frontend/features/chat/types/chat-runtime.ts
+++ b/frontend/features/chat/types/chat-runtime.ts
@@ -1,8 +1,8 @@
import type {
ChatInlineAlert,
+ ChatMessageProcessTrace,
ImageLoadingAspectRatio,
MessageAttachment,
- ChatMessageProcessTrace,
} from "@/features/chat/types/messages";
import type { ConversationOptions } from "@/shared/api/conversation.types";
import type { PublicModelPricingDTO } from "@/shared/api/model.types";
@@ -30,6 +30,13 @@ export type ChatModelOption = {
nativeToolKeys: string[];
nativeTools: ModelNativeToolConfig[];
pricing: PublicModelPricingDTO | null;
+ videoExtension: ModelMediaTaskConfig | null;
+};
+
+export type ModelMediaTaskConfig = {
+ enabled: boolean;
+ defaultOptions: ConversationOptions;
+ optionControls: ModelOptionControl[];
};
export type ModelOptionControlType = "boolean" | "number" | "select" | "text";
diff --git a/frontend/i18n/messages/en-US/admin-billing.json b/frontend/i18n/messages/en-US/admin-billing.json
index 1d83bade9..289549373 100644
--- a/frontend/i18n/messages/en-US/admin-billing.json
+++ b/frontend/i18n/messages/en-US/admin-billing.json
@@ -335,7 +335,7 @@
"duplicateModel": "{model} appears more than once",
"pricingObject": "{model} pricing must be an object",
"invalidPricingMode": "{model}.pricingMode must be token, call, duration, or tiered",
- "durationVideoOnly": "{model}.pricingMode=duration requires the video_gen capability",
+ "durationVideoOnly": "{model}.pricingMode=duration requires a video model capability",
"invalidNumber": "{model}.{field} must be a number greater than or equal to 0",
"invalidTieredPricing": "{model}.{field} must contain a non-empty tiers array",
"invalidTieredPricingJSON": "{model}.tieredPricingJSON is not valid JSON"
diff --git a/frontend/i18n/messages/en-US/admin-models.json b/frontend/i18n/messages/en-US/admin-models.json
index 8e42ab95a..30c008c3d 100644
--- a/frontend/i18n/messages/en-US/admin-models.json
+++ b/frontend/i18n/messages/en-US/admin-models.json
@@ -39,7 +39,8 @@
"audio": "Audio",
"image_gen": "Image generation",
"image_edit": "Image editing",
- "video_gen": "Video generation"
+ "video_gen": "Video generation",
+ "video_extension": "Video extension"
},
"table": {
"searchPlaceholder": "Search model, vendor, group, or description",
diff --git a/frontend/i18n/messages/en-US/admin-upstreams.json b/frontend/i18n/messages/en-US/admin-upstreams.json
index 0cc0c76dc..ab8864697 100644
--- a/frontend/i18n/messages/en-US/admin-upstreams.json
+++ b/frontend/i18n/messages/en-US/admin-upstreams.json
@@ -13,7 +13,8 @@
"audio": "Audio",
"image_gen": "Image generation",
"image_edit": "Image editing",
- "video_gen": "Video generation"
+ "video_gen": "Video generation",
+ "video_extension": "Video extension"
},
"status": {
"active": "Active",
diff --git a/frontend/i18n/messages/en-US/chat.json b/frontend/i18n/messages/en-US/chat.json
index c7224c7f3..3b130d329 100644
--- a/frontend/i18n/messages/en-US/chat.json
+++ b/frontend/i18n/messages/en-US/chat.json
@@ -214,6 +214,7 @@
"dislikeReply": "Dislike",
"retryReply": "Retry",
"continueReply": "Continue reply",
+ "extendVideo": "Extend video",
"forkMessage": "Fork conversation here",
"forkTitle": "{title} (Fork)",
"rememberPreference": "Remember preference",
@@ -285,6 +286,8 @@
"image_task_rejects_non_image_attachments": "Image tasks only support image attachments. Remove non-image files.",
"video_generation_too_many_images": "Video generation supports one input image at most. Keep one image and try again.",
"video_task_rejects_non_image_attachments": "Video generation only supports text prompts and image attachments. Remove non-image files.",
+ "video_extension_requires_single_mp4": "Video extension accepts exactly one MP4 file. Remove the other attachments and try again.",
+ "video_extension_unsupported": "The selected model does not support video extension. Switch to a model using the xAI video protocol.",
"model_task_unsupported": "This model cannot process the current input. Select another model and try again."
},
"mediaStatus": {
@@ -502,6 +505,9 @@
"videoGeneration": "Video generation",
"videoGenerationIntro": "Video generation mode is active.",
"videoGenerationDescription": "Creates a video from the text prompt. Upload one image to use it as a reference input.",
+ "videoExtension": "Video extension",
+ "videoExtensionIntro": "Video extension mode is active.",
+ "videoExtensionDescription": "Extends the uploaded MP4 video from your prompt. Only the extension duration is configurable.",
"invalidFile": "Invalid file detected",
"invalidFileIntro": "This file cannot be used for a media task.",
"blockedDescriptions": {
@@ -511,6 +517,8 @@
"image_task_rejects_non_image_attachments": "Image tasks only support image attachments. Remove non-image files.",
"video_generation_too_many_images": "Video generation supports one input image at most.",
"video_task_rejects_non_image_attachments": "Video generation only supports text prompts and image attachments. Remove non-image files.",
+ "video_extension_requires_single_mp4": "Video extension accepts exactly one MP4 file. Remove the other attachments.",
+ "video_extension_unsupported": "The selected model does not support video extension. Switch to a model using the xAI video protocol.",
"model_task_unsupported": "The selected model cannot process this input. Switch models and try again."
}
},
diff --git a/frontend/i18n/messages/zh-CN/admin-billing.json b/frontend/i18n/messages/zh-CN/admin-billing.json
index a63d56001..af9003b17 100644
--- a/frontend/i18n/messages/zh-CN/admin-billing.json
+++ b/frontend/i18n/messages/zh-CN/admin-billing.json
@@ -335,7 +335,7 @@
"duplicateModel": "{model} 重复出现",
"pricingObject": "{model} 的定价配置必须是对象",
"invalidPricingMode": "{model}.pricingMode 必须是 token、call、duration 或 tiered",
- "durationVideoOnly": "{model}.pricingMode=duration 需要 video_gen 能力",
+ "durationVideoOnly": "{model}.pricingMode=duration 需要视频模型能力",
"invalidNumber": "{model}.{field} 必须是大于等于 0 的数字",
"invalidTieredPricing": "{model}.{field} 需要包含非空 tiers 数组",
"invalidTieredPricingJSON": "{model}.tieredPricingJSON 不是合法 JSON"
diff --git a/frontend/i18n/messages/zh-CN/admin-models.json b/frontend/i18n/messages/zh-CN/admin-models.json
index 18cc16096..d0e574547 100644
--- a/frontend/i18n/messages/zh-CN/admin-models.json
+++ b/frontend/i18n/messages/zh-CN/admin-models.json
@@ -39,7 +39,8 @@
"audio": "语音",
"image_gen": "图像生成",
"image_edit": "图像编辑",
- "video_gen": "视频生成"
+ "video_gen": "视频生成",
+ "video_extension": "视频扩展"
},
"table": {
"searchPlaceholder": "搜索模型、厂商、分组或描述",
diff --git a/frontend/i18n/messages/zh-CN/admin-upstreams.json b/frontend/i18n/messages/zh-CN/admin-upstreams.json
index a860000f3..11fd33c9b 100644
--- a/frontend/i18n/messages/zh-CN/admin-upstreams.json
+++ b/frontend/i18n/messages/zh-CN/admin-upstreams.json
@@ -13,7 +13,8 @@
"audio": "语音",
"image_gen": "图像生成",
"image_edit": "图像编辑",
- "video_gen": "视频生成"
+ "video_gen": "视频生成",
+ "video_extension": "视频扩展"
},
"status": {
"active": "正常",
diff --git a/frontend/i18n/messages/zh-CN/chat.json b/frontend/i18n/messages/zh-CN/chat.json
index 9437c614e..c304a6059 100644
--- a/frontend/i18n/messages/zh-CN/chat.json
+++ b/frontend/i18n/messages/zh-CN/chat.json
@@ -214,6 +214,7 @@
"dislikeReply": "点踩",
"retryReply": "重试",
"continueReply": "继续回复",
+ "extendVideo": "扩展视频",
"forkMessage": "分支到新聊天",
"forkTitle": "{title}(分支)",
"rememberPreference": "记住偏好",
@@ -285,6 +286,8 @@
"image_task_rejects_non_image_attachments": "图像任务仅支持图片附件,请移除非图片文件。",
"video_generation_too_many_images": "视频生成最多支持一张输入图片,请保留一张图片后重试。",
"video_task_rejects_non_image_attachments": "视频生成仅支持文字提示和图片附件,请移除非图片文件。",
+ "video_extension_requires_single_mp4": "视频扩展仅支持一个 MP4 文件,请移除其他附件后重试。",
+ "video_extension_unsupported": "当前模型不支持视频扩展,请切换至支持 xAI 视频协议的模型。",
"model_task_unsupported": "该模型无法处理当前输入,请切换模型后重试。"
},
"mediaStatus": {
@@ -502,6 +505,9 @@
"videoGeneration": "视频生成",
"videoGenerationIntro": "当前为视频生成模式。",
"videoGenerationDescription": "将根据文字提示生成视频;上传一张图片后会作为视频参考输入。",
+ "videoExtension": "视频扩展",
+ "videoExtensionIntro": "当前为视频扩展模式。",
+ "videoExtensionDescription": "将根据文字提示扩展已上传的 MP4 视频,仅需设置扩展时长。",
"invalidFile": "监测到无效文件",
"invalidFileIntro": "当前文件无法用于媒体任务。",
"blockedDescriptions": {
@@ -511,6 +517,8 @@
"image_task_rejects_non_image_attachments": "图像任务仅支持图片附件,请移除非图片文件。",
"video_generation_too_many_images": "视频生成最多支持一张输入图片。",
"video_task_rejects_non_image_attachments": "视频生成仅支持文字提示和图片附件,请移除非图片文件。",
+ "video_extension_requires_single_mp4": "视频扩展仅支持一个 MP4 文件,请移除其他附件。",
+ "video_extension_unsupported": "当前模型不支持视频扩展,请切换至支持 xAI 视频协议的模型。",
"model_task_unsupported": "当前模型不支持此输入,请切换模型后重试。"
}
},
diff --git a/frontend/shared/api/conversation.ts b/frontend/shared/api/conversation.ts
index 117d133c6..dbf862cd9 100644
--- a/frontend/shared/api/conversation.ts
+++ b/frontend/shared/api/conversation.ts
@@ -28,6 +28,7 @@ import type {
CreateConversationShareRequest,
DeleteConversationData,
MediaImageRequest,
+ MediaVideoExtensionRequest,
MediaVideoRequest,
MessageDTO,
MessageFeedbackResult,
@@ -1175,3 +1176,18 @@ export async function streamVideoGeneration(
options,
);
}
+
+export async function streamVideoExtension(
+ accessToken: string,
+ conversationPublicID: string,
+ payload: MediaVideoExtensionRequest,
+ options: ConversationStreamOptions = {},
+): Promise {
+ return postConversationStream(
+ accessToken,
+ conversationPublicID,
+ "/media/videos/extensions/stream",
+ payload,
+ options,
+ );
+}
diff --git a/frontend/shared/api/conversation.types.ts b/frontend/shared/api/conversation.types.ts
index 3ad414eff..ba1133859 100644
--- a/frontend/shared/api/conversation.types.ts
+++ b/frontend/shared/api/conversation.types.ts
@@ -5,6 +5,7 @@ import type {
CreateConversationProjectRequest as ContractCreateConversationProjectRequest,
CreateConversationRequest as ContractCreateConversationRequest,
CreateConversationShareRequest as ContractCreateConversationShareRequest,
+ MediaVideoExtensionRequest as ContractMediaVideoExtensionRequest,
RenameConversationRequest as ContractRenameConversationRequest,
ReorderConversationProjectsRequest as ContractReorderConversationProjectsRequest,
RevokeConversationSharesRequest as ContractRevokeConversationSharesRequest,
@@ -220,6 +221,10 @@ export type MediaVideoRequest = {
branchReason?: "default" | "retry" | "edit";
};
+export type MediaVideoExtensionRequest = Omit & {
+ options?: ConversationOptions;
+};
+
export type SendMessageResult = Omit & {
userMessage: MessageDTO;
assistantMessage: MessageDTO;
diff --git a/frontend/shared/components/file-preview/preview-media.tsx b/frontend/shared/components/file-preview/preview-media.tsx
index e8232a506..217fc1045 100644
--- a/frontend/shared/components/file-preview/preview-media.tsx
+++ b/frontend/shared/components/file-preview/preview-media.tsx
@@ -1,10 +1,10 @@
"use client";
-import * as React from "react";
-import { createPortal } from "react-dom";
import { FileAudio2, Maximize2, Minimize2, Minus, Pause, Play, Plus } from "lucide-react";
import Image from "next/image";
import { useTranslations } from "next-intl";
+import * as React from "react";
+import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -542,13 +542,20 @@ export function PreviewMedia({
className={cn(
"relative max-w-full",
!inline && "mx-auto",
- videoIsFullscreen ? "flex h-full w-full items-center justify-center bg-neutral-950 p-6" : "w-full",
+ videoIsFullscreen
+ ? "flex h-screen w-screen max-w-none items-center justify-center bg-neutral-950"
+ : "w-full",
)}
>
void togglePlay()}
onDoubleClick={() => void toggleVideoFullscreen()}
@@ -581,35 +592,59 @@ export function PreviewMedia({
variant="ghost"
size="icon"
aria-label={tPreview("playVideo")}
- className="absolute left-1/2 top-1/2 z-20 size-12 -translate-x-1/2 -translate-y-1/2 rounded-full bg-neutral-950/75 text-neutral-50 backdrop-blur-md hover:bg-neutral-950/90 hover:text-neutral-50"
+ className={cn(
+ "absolute left-1/2 top-1/2 z-20 -translate-x-1/2 -translate-y-1/2 rounded-full text-neutral-50 backdrop-blur-md hover:text-neutral-50",
+ inline
+ ? "size-11 border border-white/15 bg-neutral-950/55 shadow-lg shadow-black/20 hover:bg-neutral-950/70"
+ : "size-12 bg-neutral-950/75 hover:bg-neutral-950/90",
+ )}
onClick={() => void togglePlay()}
>
-
+
) : null}
-
-
+
+
{formatTime(currentTime)}
-
+
@@ -628,7 +663,10 @@ export function PreviewMedia({
variant="ghost"
size="icon"
aria-label={videoIsFullscreen ? tPreview("exitFullscreen") : tPreview("enterFullscreen")}
- className="size-7 shrink-0 rounded-full text-neutral-300 hover:bg-neutral-50/10 hover:text-neutral-50"
+ className={cn(
+ "shrink-0 rounded-full text-neutral-300 hover:bg-neutral-50/10 hover:text-neutral-50",
+ inline ? "size-6" : "size-7",
+ )}
onClick={() => void toggleVideoFullscreen()}
>
{videoIsFullscreen ?
:
}
diff --git a/frontend/shared/components/markdown/streamdown-components.tsx b/frontend/shared/components/markdown/streamdown-components.tsx
index e354bc1b8..bc485b40e 100644
--- a/frontend/shared/components/markdown/streamdown-components.tsx
+++ b/frontend/shared/components/markdown/streamdown-components.tsx
@@ -1,8 +1,8 @@
"use client";
-import * as React from "react";
import { CornerUpLeft, Download, Eye, Maximize2, WandSparkles } from "lucide-react";
import { useTranslations } from "next-intl";
+import * as React from "react";
import { ChevronDown } from "@/components/animate-ui/icons/chevron-down";
import { ChevronUp } from "@/components/animate-ui/icons/chevron-up";
@@ -10,6 +10,13 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { CopyActionButton } from "@/shared/components/copy-action";
+import { MediaActionBar, MediaActionButton } from "@/shared/components/media-action-bar";
+import {
+ type ArtifactPreviewKind,
+ resolveArtifactPreviewKind,
+} from "@/shared/lib/artifact-preview";
import {
downloadMarkdownImageSource,
loadProtectedMarkdownImageBlobURL,
@@ -17,12 +24,6 @@ import {
resolveMarkdownImageSource,
resolveProtectedMarkdownImageSource,
} from "@/shared/lib/markdown-image-source";
-import {
- resolveArtifactPreviewKind,
- type ArtifactPreviewKind,
-} from "@/shared/lib/artifact-preview";
-import { CopyActionButton } from "@/shared/components/copy-action";
-import { cn } from "@/lib/utils";
import { sanitizeHTMLStyle } from "./streamdown-style";
const CODE_BLOCK_COLLAPSE_LINE_THRESHOLD = 16;
@@ -708,54 +709,24 @@ export function MarkdownImage({ alt, className, onError, onLoad, src, ...props }
/>
)}
{canUseImageActions ? (
-
-
-
-
-
- {t("previewImage")}
-
+ setPreviewOpen(true)}>
+
+
{canEditImage ? (
-
-
-
-
- {t("editImage")}
-
+ imageActions?.onEditImage?.(src)}>
+
+
) : null}
-
-
-
-
- {t("downloadImage")}
-
-
+
void handleDownload()}>
+
+
+
) : null}