支持在会话输入框直接进行文件/图片的拖拽和粘贴

This commit is contained in:
xintaofei
2026-03-08 10:54:06 +08:00
parent 68e2c7f989
commit 7a4cbcb73e
24 changed files with 1335 additions and 78 deletions

View File

@@ -43,6 +43,13 @@ export interface UserResourceDisplay {
mime_type?: string | null
}
export interface UserImageDisplay {
name: string
data: string
mime_type: string
uri?: string | null
}
const BLOCKED_RESOURCE_MENTION_RE = /@([^\s@]+)\s*\[blocked[^\]]*\]/gi
const MARKDOWN_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g
@@ -54,6 +61,7 @@ export interface AdaptedMessage {
role: MessageRole
content: AdaptedContentPart[]
userResources?: UserResourceDisplay[]
userImages?: UserImageDisplay[]
timestamp: string
usage?: TurnUsage | null
duration_ms?: number | null
@@ -398,6 +406,20 @@ function addResource(
resources.push(resource)
}
function addImage(images: UserImageDisplay[], image: UserImageDisplay) {
const key = `${image.mime_type}:${image.data.length}:${image.data.slice(0, 64)}`
if (
images.some(
(item) =>
`${item.mime_type}:${item.data.length}:${item.data.slice(0, 64)}` ===
key
)
) {
return
}
images.push(image)
}
export function extractUserResourcesFromText(text: string): {
text: string
resources: UserResourceDisplay[]
@@ -480,6 +502,33 @@ function splitUserTextAndResources(
return { parts: nextParts, resources }
}
function deriveImageNameFromBlock(
block: Extract<ContentBlock, { type: "image" }>
): string {
if (block.uri && block.uri.trim().length > 0) {
return fileNameFromUri(block.uri)
}
const ext = block.mime_type.split("/")[1]?.split("+")[0] ?? "image"
return `image.${ext}`
}
function extractUserImagesFromBlocks(
blocks: ContentBlock[]
): UserImageDisplay[] {
const images: UserImageDisplay[] = []
for (const block of blocks) {
if (block.type !== "image") continue
if (!block.data || !block.mime_type) continue
addImage(images, {
name: deriveImageNameFromBlock(block),
data: block.data,
mime_type: block.mime_type,
uri: block.uri ?? null,
})
}
return images
}
/**
* Generate a stable tool call ID based on message ID and block index
*/
@@ -661,6 +710,8 @@ export function adaptMessageTurn(
turn.role === "user"
? splitUserTextAndResources(adaptedContent, text.attachedResources)
: { parts: adaptedContent, resources: [] as UserResourceDisplay[] }
const userImages =
turn.role === "user" ? extractUserImagesFromBlocks(turn.blocks) : []
return {
id: turn.id,
@@ -668,6 +719,7 @@ export function adaptMessageTurn(
content: userSplit.parts,
userResources:
userSplit.resources.length > 0 ? userSplit.resources : undefined,
userImages: userImages.length > 0 ? userImages : undefined,
timestamp: turn.timestamp,
usage: turn.usage,
duration_ms: turn.duration_ms,
@@ -695,6 +747,7 @@ export interface MessageGroup {
role: "user" | "assistant" | "system"
parts: AdaptedContentPart[]
userResources?: UserResourceDisplay[]
userImages?: UserImageDisplay[]
usage?: TurnUsage | null
duration_ms?: number | null
model?: string | null
@@ -738,6 +791,7 @@ export function groupAdaptedMessages(
role: effectiveRole,
parts: [...msg.content],
userResources: msg.userResources,
userImages: msg.userImages,
})
} else {
if (currentGroup && currentGroup.role === "assistant") {

View File

@@ -1,5 +1,6 @@
import type {
AdaptedContentPart,
UserImageDisplay,
UserResourceDisplay,
} from "@/lib/adapters/ai-elements-adapter"
import type { PromptDraft, PromptInputBlock } from "@/lib/types"
@@ -10,6 +11,35 @@ function isResourceLinkBlock(
return block.type === "resource_link"
}
function isEmbeddedResourceBlock(
block: PromptInputBlock
): block is Extract<PromptInputBlock, { type: "resource" }> {
return block.type === "resource"
}
function isImageBlock(
block: PromptInputBlock
): block is Extract<PromptInputBlock, { type: "image" }> {
return block.type === "image"
}
function deriveResourceNameFromUri(uri: string): string {
const fallback = "resource"
const normalized = uri.trim()
if (!normalized) return fallback
const withoutQuery = normalized.split(/[?#]/, 1)[0]
const candidate = withoutQuery.split(/[\\/]/).pop() ?? ""
let decoded = ""
if (candidate) {
try {
decoded = decodeURIComponent(candidate)
} catch {
decoded = candidate
}
}
return decoded || fallback
}
export function getPromptDraftDisplayText(
draft: PromptDraft,
attachedResourcesFallback: string
@@ -33,9 +63,40 @@ export function buildUserMessageTextPartsFromDraft(
export function extractUserResourcesFromDraft(
draft: PromptDraft
): UserResourceDisplay[] {
return draft.blocks.filter(isResourceLinkBlock).map((resource) => ({
const linked = draft.blocks.filter(isResourceLinkBlock).map((resource) => ({
name: resource.name,
uri: resource.uri,
mime_type: resource.mime_type ?? null,
}))
const embedded = draft.blocks
.filter(isEmbeddedResourceBlock)
.map((resource) => ({
name: deriveResourceNameFromUri(resource.uri),
uri: resource.uri,
mime_type: resource.mime_type ?? null,
}))
return [...linked, ...embedded]
}
function deriveImageName(
uri: string | null | undefined,
mimeType: string
): string {
if (uri && uri.trim().length > 0) {
const name = deriveResourceNameFromUri(uri)
if (name !== "resource") return name
}
const ext = mimeType.split("/")[1]?.split("+")[0] ?? "image"
return `image.${ext}`
}
export function extractUserImagesFromDraft(
draft: PromptDraft
): UserImageDisplay[] {
return draft.blocks.filter(isImageBlock).map((image) => ({
name: deriveImageName(image.uri, image.mime_type),
data: image.data,
mime_type: image.mime_type,
uri: image.uri ?? null,
}))
}

View File

@@ -743,6 +743,13 @@ export async function stopFileTreeWatch(rootPath: string): Promise<void> {
return invoke("stop_file_tree_watch", { rootPath })
}
export async function readFileBase64(
path: string,
maxBytes?: number
): Promise<string> {
return invoke("read_file_base64", { path, maxBytes: maxBytes ?? null })
}
export async function readFilePreview(
rootPath: string,
path: string,

View File

@@ -60,6 +60,12 @@ export type MessageRole = "user" | "assistant" | "system" | "tool"
export type ContentBlock =
| { type: "text"; text: string }
| {
type: "image"
data: string
mime_type: string
uri?: string | null
}
| {
type: "tool_use"
tool_use_id: string | null
@@ -304,8 +310,27 @@ export type ConnectionStatus =
| "disconnected"
| "error"
export interface PromptCapabilitiesInfo {
image: boolean
audio: boolean
embedded_context: boolean
}
export type PromptInputBlock =
| { type: "text"; text: string }
| {
type: "image"
data: string
mime_type: string
uri?: string | null
}
| {
type: "resource"
uri: string
mime_type?: string | null
text?: string | null
blob?: string | null
}
| {
type: "resource_link"
uri: string
@@ -430,6 +455,11 @@ export type AcpEvent =
type: "selectors_ready"
connection_id: string
}
| {
type: "prompt_capabilities"
connection_id: string
prompt_capabilities: PromptCapabilitiesInfo
}
| {
type: "mode_changed"
connection_id: string