feat: generate and edit conversation images across runtimes

Add model-controlled image invocation and read-only runtime assignments. Route image tools through Main so accepted operations retain their originating conversation when chat runtimes change, with saved references, cancellation, and manual recovery.

Keep Node hashing outside browser-shared contracts to restore production builds. Validation includes the production build, typecheck, lint, focused regressions, real Host transport checks, and two successful real image requests. Full UI-driven runtime switching acceptance remains documented as pending.

Release note: 开启图片模型的会话调用后,聊天助手可调用图片生成与编辑工具,Runtime 分配自动管理。图片成果保存在原会话,可继续修改,并独立显示生成状态和取消入口。
This commit is contained in:
mesalogo
2026-09-15 15:51:52 +08:00
parent e5aa9ed688
commit f8d07419d2
78 changed files with 2948 additions and 330 deletions
+118 -2
View File
@@ -279,6 +279,8 @@ type MessageMetadata = {
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
artifactIds?: string[]
imageOperations?: ImageOperation[]
imageSourceArtifactIds?: string[]
imageContextNotice?: ConversationSnapshot['messages'][number]['imageContextNotice']
task?: ConversationSnapshot['messages'][number]['task']
attachments?: ConversationSnapshot['messages'][number]['attachments']
@@ -1177,6 +1179,8 @@ function toConversationSnapshot(
sourceReferences: metadata.sourceReferences,
knowledgeRetrieval: metadata.knowledgeRetrieval,
artifactIds: metadata.artifactIds,
imageOperations: metadata.imageOperations,
imageSourceArtifactIds: metadata.imageSourceArtifactIds,
imageContextNotice: metadata.imageContextNotice,
task: metadata.task,
attachments: metadata.attachments,
@@ -1204,6 +1208,8 @@ function serializeConversationMessageMetadata(
sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval,
artifactIds: message.artifactIds,
imageOperations: message.imageOperations,
imageSourceArtifactIds: message.imageSourceArtifactIds,
imageContextNotice: message.imageContextNotice,
task: message.task,
attachments: message.attachments,
@@ -2862,7 +2868,7 @@ export class AssistantDatabase {
WHERE id = ? AND channel IS NULL`
)
const findMessage = database.prepare(
`SELECT conversation_id, role,
`SELECT conversation_id, role, metadata_json,
EXISTS(
SELECT 1 FROM tasks
WHERE tasks.id = messages.request_id
@@ -2941,6 +2947,7 @@ export class AssistantDatabase {
| {
conversation_id: string
role: MessageRow['role']
metadata_json: string
remote_recoverable: number
}
| undefined
@@ -2957,7 +2964,20 @@ export class AssistantDatabase {
updateMessage.run(
message.content,
message.state,
serializeConversationMessageMetadata(message),
serializeConversationMessageMetadata({
...message,
...(() => {
const stored = JSON.parse(existingMessage.metadata_json) as MessageMetadata
return {
imageOperations: stored.imageOperations,
imageSourceArtifactIds: stored.imageSourceArtifactIds,
artifactIds: stored.imageOperations?.some(operation => operation.artifactIds.length) ? [...new Set([
...(message.artifactIds ?? []),
...(stored.imageOperations?.flatMap(operation => operation.artifactIds) ?? [])
])].slice(-8) : message.artifactIds
}
})()
}),
message.id
)
continue
@@ -5705,6 +5725,101 @@ export class AssistantDatabase {
})
}
saveConversationImageOperation(
operation: ImageOperation,
image?: { mimeType: 'image/png' | 'image/jpeg' | 'image/webp'; data: string }
): ImageOperation {
const database = this.requireDatabase()
database.exec('BEGIN IMMEDIATE')
try {
const row = database.prepare(`SELECT m.metadata_json, c.project_id FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE m.id = ? AND m.conversation_id = ?`).get(operation.messageId, operation.conversationId) as
{ metadata_json: string; project_id: string | null } | undefined
if (!row) throw new Error('Image operation conversation or message no longer exists')
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
const previous = metadata.imageOperations?.find(item => item.id === operation.id)
if (previous?.state === 'completed') {
database.exec('COMMIT')
return previous
}
let saved = operation
if (image) {
const artifact = this.createImageArtifact({
projectId: row.project_id ?? undefined,
title: operation.input.prompt.slice(0, 120),
mimeType: image.mimeType,
base64: image.data
})
saved = { ...operation, state: 'completed', artifactIds: [artifact.id] }
metadata.artifactIds = [...new Set([...(metadata.artifactIds ?? []), artifact.id])].slice(-8)
}
metadata.imageOperations = [...(metadata.imageOperations ?? []).filter(item => item.id !== saved.id), saved]
database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ? AND conversation_id = ?')
.run(JSON.stringify(metadata), saved.messageId, saved.conversationId)
database.prepare('UPDATE conversations SET updated_at = MAX(updated_at, ?) WHERE id = ?')
.run(new Date(saved.updatedAt).toISOString(), saved.conversationId)
database.exec('COMMIT')
return saved
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
saveConversationImageSources(input: {
conversationId: string
messageId: string
images: readonly { name: string; mediaType: 'image/png' | 'image/jpeg'; data: string }[]
}): string[] {
if (input.images.length > 8) throw new Error('At most eight source images are supported')
const database = this.requireDatabase()
database.exec('BEGIN IMMEDIATE')
try {
const row = database.prepare(`SELECT m.metadata_json, c.project_id FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE m.id = ? AND m.conversation_id = ?`).get(input.messageId, input.conversationId) as
{ metadata_json: string; project_id: string | null } | undefined
if (!row) throw new Error('Image source conversation or message no longer exists')
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
if (!metadata.imageSourceArtifactIds?.length) {
metadata.imageSourceArtifactIds = input.images.map(image => this.createImageArtifact({
projectId: row.project_id ?? undefined, title: image.name,
mimeType: image.mediaType, base64: image.data
}).id)
database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?').run(JSON.stringify(metadata), input.messageId)
}
database.exec('COMMIT')
return metadata.imageSourceArtifactIds
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
markUnfinishedImageOperationsUnconfirmed(): void {
const database = this.requireDatabase()
database.exec('BEGIN IMMEDIATE')
try {
const rows = database.prepare("SELECT id, metadata_json FROM messages WHERE json_type(metadata_json, '$.imageOperations') = 'array'")
.all() as { id: string; metadata_json: string }[]
for (const row of rows) {
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
let changed = false
metadata.imageOperations = metadata.imageOperations?.map(operation => {
if (!['running', 'saving', 'cancelling'].includes(operation.state)) return operation
changed = true
return { ...operation, state: 'unconfirmed', updatedAt: Date.now() }
})
if (changed) database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?').run(JSON.stringify(metadata), row.id)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
createImageArtifact(input: {
projectId?: string
taskId?: string
@@ -9764,3 +9879,4 @@ export class AssistantDatabase {
return this.database
}
}
import type { ImageOperation } from '../../shared/image-generation-contracts'