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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions backend/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -14225,9 +14225,10 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "由浏览器提交完整纯文本上下文;服务端不创建会话、消息、运行或断线续传记录",
"description": "由浏览器提交完整上下文和可选请求级附件;服务端不创建会话、消息、运行、文件或断线续传记录",
"consumes": [
"application/json"
"application/json",
"multipart/form-data"
],
"produces": [
"application/x-ndjson"
Expand Down
5 changes: 3 additions & 2 deletions backend/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -14218,9 +14218,10 @@
"BearerAuth": []
}
],
"description": "由浏览器提交完整纯文本上下文;服务端不创建会话、消息、运行或断线续传记录",
"description": "由浏览器提交完整上下文和可选请求级附件;服务端不创建会话、消息、运行、文件或断线续传记录",
"consumes": [
"application/json"
"application/json",
"multipart/form-data"
],
"produces": [
"application/x-ndjson"
Expand Down
3 changes: 2 additions & 1 deletion backend/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18729,7 +18729,8 @@ paths:
post:
consumes:
- application/json
description: 由浏览器提交完整纯文本上下文;服务端不创建会话、消息、运行或断线续传记录
- multipart/form-data
description: 由浏览器提交完整上下文和可选请求级附件;服务端不创建会话、消息、运行、文件或断线续传记录
parameters:
- description: 临时对话参数
in: body
Expand Down
47 changes: 46 additions & 1 deletion backend/internal/application/contentmoderation/coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,57 @@ func (c *RunCoordinator) EnqueueInputImages(ctx context.Context, fileIDs []strin
if len(raw) == 0 {
return
}
c.enqueueInputImageSources(raw, keptFiles, selected)
}

// EnqueueInputImageSources queues request-scoped image bytes without requiring
// persisted file records. Ephemeral chat uses this path so its images follow the
// same moderation policy while remaining outside the user file library.
func (c *RunCoordinator) EnqueueInputImageSources(images []OutputImageSource) {
if c == nil {
return
}
selected := c.cfg.Policy.CategoriesFor(domaincm.DirectionInput, domaincm.ModalityImage)
if len(selected) == 0 || len(images) == 0 {
return
}
seenSHA := make(map[string]struct{})
raw := make([]OutputImageSource, 0, len(images))
fileIDs := make([]string, 0, len(images))
for _, image := range images {
if len(image.Data) == 0 {
c.recordSurfaceFailure(domaincm.DirectionInput, domaincm.ModalityImage, image.FileID, ErrModerationInvalidResp)
continue
}
sha := strings.TrimSpace(image.SHA256)
if sha != "" {
if _, exists := seenSHA[sha]; exists {
continue
}
seenSHA[sha] = struct{}{}
}
fileID := strings.TrimSpace(image.FileID)
raw = append(raw, OutputImageSource{
FileID: fileID,
Data: append([]byte(nil), image.Data...),
MimeType: strings.TrimSpace(image.MimeType),
SHA256: sha,
})
fileIDs = append(fileIDs, fileID)
}
if len(raw) == 0 {
return
}
c.enqueueInputImageSources(raw, fileIDs, selected)
}

func (c *RunCoordinator) enqueueInputImageSources(raw []OutputImageSource, fileIDs []string, selected []string) {
c.startTask(&moderationTask{
Coord: c,
Direction: domaincm.DirectionInput,
Modality: domaincm.ModalityImage,
RawImages: raw,
FileIDs: keptFiles,
FileIDs: fileIDs,
Selected: selected,
Location: domaincm.ContentLocation{Field: "user_attachments"},
// Input hits must isolate but not revoke user library files.
Expand Down
23 changes: 23 additions & 0 deletions backend/internal/application/contentmoderation/coordinator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,29 @@ func TestInputImageModerationSkipsNonImageAttachments(t *testing.T) {
}
}

func TestEphemeralInputImageModerationQueuesRequestScopedBytes(t *testing.T) {
service := NewService(nil, &coordinatorTestRepo{}, "", nil)
cfg := runtimeConfig{Policy: Policy{InputImageCategories: []string{"violence"}}}
coord := newRunCoordinator(service, RunMeta{RunID: "run_ephemeral_image", Ephemeral: true}, cfg)

coord.EnqueueInputImageSources([]OutputImageSource{
{FileID: "temporary_image", Data: []byte("image-bytes"), MimeType: "image/png", SHA256: "sha"},
{FileID: "temporary_duplicate", Data: []byte("duplicate"), MimeType: "image/png", SHA256: "sha"},
})

select {
case task := <-service.taskQueue:
if task == nil || task.Modality != domaincm.ModalityImage || !task.IsolateOnly {
t.Fatalf("unexpected request-scoped moderation task: %#v", task)
}
if len(task.RawImages) != 1 || string(task.RawImages[0].Data) != "image-bytes" {
t.Fatalf("request-scoped image bytes were not queued safely: %#v", task.RawImages)
}
default:
t.Fatal("request-scoped image moderation task was not queued")
}
}

func TestRecordHitRollsBackIsolatedImagesWhenEventCreateFails(t *testing.T) {
repo := &coordinatorTestRepo{createErr: errors.New("database unavailable")}
store := &coordinatorTestObjectStore{}
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/application/contentmoderation/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ type PreparedImage struct {
// ImageLoader loads and prepares an image for moderation.
type ImageLoader func(ctx context.Context, userID uint, fileID string) (PreparedImage, error)

// OutputImageSource provides final generated image bytes for isolation.
// OutputImageSource provides request-scoped image bytes and optional source metadata for moderation.
type OutputImageSource struct {
FileID string
Data []byte
Expand Down
80 changes: 52 additions & 28 deletions backend/internal/application/conversation/service_temporary_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,28 @@ import (

const temporaryChatMaxContentChars = 1_000_000

// TemporaryChatMessage 是浏览器内临时对话的一条纯文本消息
// TemporaryChatMessage 是浏览器内临时对话的一条消息
type TemporaryChatMessage struct {
Role string
Content string
}

// TemporaryChatInput 描述不创建会话、消息和运行记录的临时推理请求。
type TemporaryChatInput struct {
UserID uint
RequestID string
SessionID string
ClientRunID string
Model string
Options map[string]interface{}
SelectedToolIDs []uint
SkillIDs []uint
KnowledgeBaseIDs []string
HTMLVisualPromptEnabled bool
Messages []TemporaryChatMessage
OnEvent func(eventType string, payload map[string]interface{}) error
UserID uint
RequestID string
SessionID string
ClientRunID string
Model string
Options map[string]interface{}
SelectedToolIDs []uint
SkillIDs []uint
KnowledgeBaseIDs []string
HTMLVisualPromptEnabled bool
Messages []TemporaryChatMessage
Attachments []TemporaryChatAttachment
ReleaseAttachmentSources func()
OnEvent func(eventType string, payload map[string]interface{}) error
}

// StreamTemporaryChat 直接以请求上下文调用上游。调用方断开连接时生成随即取消,
Expand Down Expand Up @@ -82,6 +84,14 @@ func (s *Service) StreamTemporaryChat(
for _, item := range input.Messages {
messages = append(messages, llm.Message{Role: item.Role, Content: item.Content})
}
attachmentContext, err := s.prepareTemporaryAttachmentContext(ctx, input, messages)
if input.ReleaseAttachmentSources != nil {
input.ReleaseAttachmentSources()
}
if err != nil {
return nil, err
}
messages = attachmentContext.messages
systemPrompt := resolveMessageSystemPromptInjection(cfg, route, "", input.HTMLVisualPromptEnabled)
if systemPrompt.Content != "" {
if systemPrompt.InlineToUser {
Expand All @@ -97,8 +107,8 @@ func (s *Service) StreamTemporaryChat(
if err != nil {
return nil, err
}
// Attachment processors require persisted file objects and are therefore not
// exposed in a text-only temporary request.
// Attachment processors depend on persisted file IDs. Request-scoped temporary
// attachments are injected directly into the model context instead.
toolRuntime = toolRuntime.withoutAttachmentProcessor()
skillPrompts, err := s.resolveSkillPrompts(ctx, SendMessageInput{
UserID: input.UserID,
Expand All @@ -115,11 +125,12 @@ func (s *Service) StreamTemporaryChat(
return nil, err
}
promptPlan := buildPromptPlan(ctx, promptPlanInput{
BaseMessages: messages,
DynamicContext: knowledgeContext,
SkillPrompts: skillPrompts,
ToolRuntime: toolRuntime,
Config: cfg,
BaseMessages: messages,
StableAttachments: attachmentContext.stableAttachments,
DynamicContext: knowledgeContext,
SkillPrompts: skillPrompts,
ToolRuntime: toolRuntime,
Config: cfg,
})
messages = stripTemporaryMessageCacheControls(promptPlan.Messages)
fullMessages := cloneLLMMessages(messages)
Expand All @@ -145,15 +156,15 @@ func (s *Service) StreamTemporaryChat(
})
}
moderationCoord.EnqueueInputText(lastUser.Content)
moderationCoord.EnqueueInputImageSources(attachmentContext.moderationImages)
}
}
generateInput := llm.GenerateInput{
RequestID: strings.TrimSpace(input.RequestID),
Messages: messages,
Tools: toolRuntime.definitions,
DisableTools: len(toolRuntime.definitions) == 0,
Options: filteredOptions,
Ephemeral: true,
RequestID: strings.TrimSpace(input.RequestID),
Messages: messages,
Tools: toolRuntime.definitions,
Options: filteredOptions,
Ephemeral: true,
}
var budgetFit promptBudgetFit
generateInput, budgetFit = fitGenerateInputToModelBudget(
Expand Down Expand Up @@ -345,6 +356,9 @@ func ValidateTemporaryChatInput(input TemporaryChatInput) error {
if len(input.Messages) == 0 || len(input.Messages) > 100 {
return ErrInvalidMessageContent
}
if len(input.Attachments) > TemporaryChatMaxAttachments {
return ErrTooManyMessageFiles
}
if len(input.KnowledgeBaseIDs) > 8 {
return ErrInvalidMessageContent
}
Expand All @@ -359,12 +373,22 @@ func ValidateTemporaryChatInput(input TemporaryChatInput) error {
}
seenKnowledgeBases[normalized] = struct{}{}
}
attachmentCounts := make(map[int]int)
for _, attachment := range input.Attachments {
if attachment.MessageIndex < 0 || attachment.MessageIndex >= len(input.Messages) || attachment.Reader == nil {
return ErrInvalidFileReference
}
attachmentCounts[attachment.MessageIndex]++
}
totalChars := 0
previousRole := ""
for _, item := range input.Messages {
for index, item := range input.Messages {
role := strings.TrimSpace(item.Role)
content := strings.TrimSpace(item.Content)
if (role != "user" && role != "assistant") || content == "" || role == previousRole {
if (role != "user" && role != "assistant") || role == previousRole {
return ErrInvalidMessageContent
}
if content == "" && (role != "user" || attachmentCounts[index] == 0) {
return ErrInvalidMessageContent
}
totalChars += len([]rune(item.Content))
Expand Down
Loading
Loading