Add web multimodal image attachments
This commit is contained in:
@@ -12,7 +12,9 @@ import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-
|
||||
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
|
||||
import type { SelectionTarget } from './contract/views.ts'
|
||||
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
|
||||
import type {
|
||||
ComposerAttachment, ConversationInjected, DetailsInjected, EmptyStateInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import { ToolViewRegistry } from './toolviews/registry.ts'
|
||||
@@ -92,15 +94,25 @@ export function apply(ctx: Context): void {
|
||||
subscribe: fn => conversation.subscribeViews(fn),
|
||||
version: () => conversation.viewsVersion(),
|
||||
},
|
||||
send: (text, mode) => {
|
||||
addImages: (files) => {
|
||||
const images = conversation.createDraftImages(files)
|
||||
actions.addImages(images.map(image => image.id))
|
||||
},
|
||||
removeImage: (id) => {
|
||||
conversation.releaseDraftImage(id)
|
||||
actions.removeImage(id)
|
||||
},
|
||||
draftImages: ids => conversation.draftImages(ids),
|
||||
send: (text, images: readonly ComposerAttachment[], mode) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return
|
||||
if (trimmed === '' && images.length === 0) return
|
||||
// Optimistic clear with failure restore (choreography lives with the
|
||||
// sender; the business failure also lands in snapshot.promptError).
|
||||
// The store write path stays inside the declared actions set:
|
||||
// restoreDraft itself no-ops once the user typed something new.
|
||||
// The store write path stays inside the declared actions set.
|
||||
actions.clearDraft()
|
||||
void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) })
|
||||
void scoped.send(trimmed, mode, images.map(image => image.file))
|
||||
.then(() => { conversation.releaseDraftImages(images) })
|
||||
.catch(() => { actions.restoreDraft(trimmed, images.map(image => image.id)) })
|
||||
},
|
||||
stop: () => {
|
||||
scoped.cancel().catch(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { memo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconThinkOutline14, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
export interface AssistantMarkdownProps {
|
||||
@@ -15,6 +16,7 @@ export interface AssistantMarkdownProps {
|
||||
streaming: boolean
|
||||
/** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */
|
||||
interrupted?: boolean | undefined
|
||||
loadImage?: ImageLoader
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
@@ -36,14 +38,17 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, loadImage = unavailableImage }: AssistantMarkdownProps) {
|
||||
const last = blocks.length - 1
|
||||
const images = blocks.filter((block): block is Extract<AssistantBlock, { kind: 'image' }> => block.kind === 'image')
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<ImageGallery images={images} load={loadImage} align="start" />
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return <MessageText key={i} text={block.text} />
|
||||
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
|
||||
case 'image': return null
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass.
|
||||
case 'tool-call': return null
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
@@ -54,3 +59,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function unavailableImage(): Promise<string> {
|
||||
return Promise.reject(new Error('图片读取服务不可用'))
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
// map but only rows whose own selected bit flipped.
|
||||
|
||||
import {
|
||||
memo, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode,
|
||||
memo, useCallback, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode,
|
||||
} from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -24,6 +25,7 @@ import type { ToolViewResolver } from '../contract/toolview.ts'
|
||||
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { MessageItem } from './MessageItem.tsx'
|
||||
import type { ImageLoader } from './MessageImage.tsx'
|
||||
import { PendingCard } from './PendingCard.tsx'
|
||||
import { ToolViewOutlet } from './ToolViewOutlet.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
@@ -32,6 +34,7 @@ import css from './ChatView.module.css'
|
||||
export interface ChatViewDeps {
|
||||
toolviews: ToolViewResolver
|
||||
t: Translate
|
||||
resolveImage?(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string>
|
||||
}
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
@@ -102,16 +105,17 @@ const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t,
|
||||
|
||||
/** The streaming partial, isolated so chunk batches re-render only this tail.
|
||||
* onGrow lets the scroll owner follow content the parent never re-renders for. */
|
||||
function StreamingTail({ useSession, onGrow }: {
|
||||
function StreamingTail({ useSession, onGrow, loadImage }: {
|
||||
useSession: UseConversation
|
||||
onGrow: () => void
|
||||
loadImage: ImageLoader
|
||||
}) {
|
||||
const partial = useSession((s) => s.partial)
|
||||
useLayoutEffect(() => {
|
||||
onGrow()
|
||||
})
|
||||
if (partial === null) return null
|
||||
return <AssistantMarkdown blocks={partial.blocks} streaming />
|
||||
return <AssistantMarkdown blocks={partial.blocks} streaming loadImage={loadImage} />
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,7 +124,7 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
* @returns the ConvViewProps component registered as the chat view.
|
||||
*/
|
||||
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
const { toolviews, t } = deps
|
||||
const { toolviews, t, resolveImage = unavailableImage } = deps
|
||||
|
||||
return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) {
|
||||
const useSession = useSessionWide as UseConversation
|
||||
@@ -132,6 +136,10 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
const hasMore = useSession((s) => s.hasMore)
|
||||
const loadingOlder = useSession((s) => s.loadingOlder)
|
||||
const selectedCallId = useStore((s) => s.selection?.callId)
|
||||
const loadImage = useCallback<ImageLoader>(
|
||||
attachment => resolveImage(sessionId, attachment),
|
||||
[resolveImage, sessionId],
|
||||
)
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
|
||||
@@ -229,11 +237,11 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
}
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
|
||||
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} loadImage={loadImage} />
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return <MessageItem key={item.key} node={node} />
|
||||
return <MessageItem key={item.key} node={node} loadImage={loadImage} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -250,7 +258,7 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
</div>
|
||||
)}
|
||||
{items.map(renderItem)}
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} loadImage={loadImage} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map((call) => (
|
||||
@@ -291,3 +299,7 @@ export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableImage(): Promise<string> {
|
||||
return Promise.reject(new Error('图片读取服务不可用'))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: min(240px, 100%);
|
||||
}
|
||||
|
||||
.gallery[data-align='end'] {
|
||||
justify-content: flex-end;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.gallery[data-align='start'] {
|
||||
justify-content: flex-start;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.frame {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.frame img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.error {
|
||||
max-width: 240px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { ImageLightbox } from '../skeleton/ImageLightbox.tsx'
|
||||
import css from './MessageImage.module.css'
|
||||
|
||||
/** Loads a session-authorized durable image URL. */
|
||||
export type ImageLoader = (attachment: ImageAttachmentRef) => Promise<string>
|
||||
|
||||
/** Compact history renderer with retryable loading and double-click original preview. */
|
||||
export function MessageImage({ attachment, alt, load }: {
|
||||
attachment: ImageAttachmentRef
|
||||
alt?: string
|
||||
load: ImageLoader
|
||||
}) {
|
||||
const [src, setSrc] = useState<string | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
const size = useMemo(() => {
|
||||
const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height)
|
||||
return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) }
|
||||
}, [attachment.height, attachment.width])
|
||||
|
||||
const request = useCallback(() => {
|
||||
setError(false)
|
||||
setSrc(null)
|
||||
void load(attachment).then(setSrc).catch(() => { setError(true) })
|
||||
}, [attachment, load])
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
setError(false)
|
||||
void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) })
|
||||
return () => { live = false }
|
||||
}, [attachment, load])
|
||||
|
||||
const label = alt ?? attachment.name ?? '图片'
|
||||
if (error) return <button type="button" className={css.error} onClick={request}>图片加载失败,点击重试</button>
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.frame}
|
||||
style={size}
|
||||
title="双击查看原图"
|
||||
aria-label={`${label},双击查看原图`}
|
||||
onDoubleClick={() => { if (src !== null) setOpen(true) }}
|
||||
>
|
||||
{src === null ? <span className={css.loading}>图片加载中…</span> : <img src={src} alt={label} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} onClose={close} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Wrapping image group shared by user and assistant history. */
|
||||
export function ImageGallery({ images, load, align }: {
|
||||
images: readonly { attachment: ImageAttachmentRef; alt?: string }[]
|
||||
load: ImageLoader
|
||||
align: 'start' | 'end'
|
||||
}) {
|
||||
if (images.length === 0) return null
|
||||
return (
|
||||
<div className={css.gallery} data-align={align}>
|
||||
{images.map((image, index) => (
|
||||
<MessageImage key={`${image.attachment.attachmentId}:${index}`} {...image} load={load} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,9 +7,18 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.userStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: min(525px, 82%);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
|
||||
max-width: min(525px, 82%);
|
||||
max-width: 100%;
|
||||
background: var(--dsw-specific-bubble);
|
||||
border-radius: 22px;
|
||||
/* 44px single-line bubble: 24 line + 10 vertical padding each side. */
|
||||
|
||||
@@ -9,33 +9,49 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './MessageItem.module.css'
|
||||
import { ImageGallery, type ImageLoader } from './MessageImage.tsx'
|
||||
|
||||
export interface MessageItemProps {
|
||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
|
||||
loadImage?: ImageLoader
|
||||
}
|
||||
|
||||
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
||||
type UserImage = Extract<UserMessageNode['content'][number], { type: 'image' }>
|
||||
|
||||
function contentParts(content: readonly unknown[]): {
|
||||
text: string
|
||||
images: { attachment: UserImage['attachment']; alt?: string }[]
|
||||
rest: unknown[]
|
||||
} {
|
||||
const texts: string[] = []
|
||||
const images: { attachment: UserImage['attachment']; alt?: string }[] = []
|
||||
const rest: unknown[] = []
|
||||
for (const block of content) {
|
||||
const b = block as { type?: string; text?: string }
|
||||
const b = block as { type?: string; text?: string; attachment?: unknown; alt?: string }
|
||||
if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text)
|
||||
else if (b.type === 'image' && b.attachment !== undefined) {
|
||||
const image = b as UserImage
|
||||
images.push({ attachment: image.attachment, ...image.alt === undefined ? {} : { alt: image.alt } })
|
||||
}
|
||||
else rest.push(block)
|
||||
}
|
||||
return { text: texts.join(''), rest }
|
||||
return { text: texts.join(''), images, rest }
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
|
||||
export const MessageItem = memo(function MessageItem({ node, loadImage = unavailableImage }: MessageItemProps) {
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
case 'steering': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
const { text, images, rest } = contentParts(node.content)
|
||||
return (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.bubble}>
|
||||
{node.kind === 'steering' && <span className={css.badge}>插话</span>}
|
||||
<MessageText text={text} />
|
||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||
<div className={css.userStack}>
|
||||
<ImageGallery images={images} load={loadImage} align="end" />
|
||||
{(text !== '' || rest.length > 0 || node.kind === 'steering') && <div className={css.bubble}>
|
||||
{node.kind === 'steering' && <span className={css.badge}>插话</span>}
|
||||
<MessageText text={text} />
|
||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -54,3 +70,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function unavailableImage(): Promise<string> {
|
||||
return Promise.reject(new Error('图片读取服务不可用'))
|
||||
}
|
||||
|
||||
@@ -46,7 +46,11 @@ export function registerChat(deps: RegisterChatDeps): () => void {
|
||||
id: 'chat',
|
||||
label: 'Chat',
|
||||
order: 0,
|
||||
component: createChatView({ toolviews, t }),
|
||||
component: createChatView({
|
||||
toolviews,
|
||||
t,
|
||||
resolveImage: (sessionId, attachment) => conversation.resolveImage(sessionId, attachment),
|
||||
}),
|
||||
chrome: { footer: StatsLine },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { SelectionTarget, ViewEntry } from './views.ts'
|
||||
|
||||
/** Browser-owned image that has not crossed the durable host boundary. */
|
||||
export interface ComposerAttachment {
|
||||
id: string
|
||||
file: File
|
||||
previewUrl: string
|
||||
}
|
||||
|
||||
/** The shared chat store handle type (apply constructs one; conversation and details both declare it). */
|
||||
export type ChatStore = ReturnType<typeof createChatStore>
|
||||
|
||||
@@ -29,8 +36,14 @@ export interface ConversationInjected {
|
||||
subscribe(fn: () => void): () => void
|
||||
version(): number
|
||||
}
|
||||
/** Create browser previews and append their ids through the declared store action. */
|
||||
addImages(files: readonly File[]): void
|
||||
/** Release one browser preview and remove its id through the declared store action. */
|
||||
removeImage(id: string): void
|
||||
/** Resolve ordered store ids to the browser-owned draft attachments still available this runtime. */
|
||||
draftImages(ids: readonly string[]): readonly ComposerAttachment[]
|
||||
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
|
||||
send(text: string, mode: 'queue' | 'steer'): void
|
||||
send(text: string, images: readonly ComposerAttachment[], mode: 'queue' | 'steer'): void
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
stop(): void
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
@@ -60,7 +73,12 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
||||
/** Injected share of the no-session empty-state slot. */
|
||||
export interface EmptyStateInjected {
|
||||
/** The create → navigate → first-send chain, in one service call. */
|
||||
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
|
||||
startSession(opts: {
|
||||
cwd?: string
|
||||
text: string
|
||||
images?: readonly File[]
|
||||
mode: 'queue' | 'steer'
|
||||
}): Promise<void>
|
||||
}
|
||||
|
||||
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
|
||||
|
||||
@@ -69,6 +69,12 @@ export interface ChatStoreState {
|
||||
selection: SelectionTarget | null
|
||||
/** Composer draft (persisted; survives session switches and reloads). */
|
||||
draft: string
|
||||
/**
|
||||
* Ordered browser-draft attachment ids. The matching File/object-URL
|
||||
* objects stay in ConversationService because they are runtime-only; stale
|
||||
* persisted ids are pruned by ConversationRoot after a page reload.
|
||||
*/
|
||||
imageIds: string[]
|
||||
/** Active conversation view id; null falls back to the first registered view. */
|
||||
view: ViewId | null
|
||||
}
|
||||
|
||||
@@ -22,8 +22,27 @@ import type { Context } from 'cordis'
|
||||
// SessionsService tags contexts with — scopeOf then always returns undefined
|
||||
// in the browser while unit tests (single-instance path resolution) stay green.
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ViewEntry, ViewId } from './index.ts'
|
||||
import type { ComposerAttachment } from './contract/slots.ts'
|
||||
|
||||
/** Opaque wrapper keeps browser `File` internals outside persisted store state. */
|
||||
class BrowserDraftAttachment implements ComposerAttachment {
|
||||
readonly id: string
|
||||
readonly previewUrl: string
|
||||
readonly #file: File
|
||||
|
||||
constructor(file: File) {
|
||||
this.id = crypto.randomUUID()
|
||||
this.previewUrl = URL.createObjectURL(file)
|
||||
this.#file = file
|
||||
}
|
||||
|
||||
get file(): File {
|
||||
return this.#file
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
|
||||
interface ViewsState {
|
||||
@@ -36,6 +55,9 @@ interface ViewsState {
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
private readonly draftAttachments = new Map<string, BrowserDraftAttachment>()
|
||||
private readonly imageUrls = new Map<string, Promise<string>>()
|
||||
private readonly createdImageUrls = new Set<string>()
|
||||
private readonly viewsState: ViewsState = {
|
||||
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
|
||||
}
|
||||
@@ -46,6 +68,12 @@ export class ConversationService extends Service {
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'conversation')
|
||||
ctx.effect(() => () => {
|
||||
for (const url of this.createdImageUrls) URL.revokeObjectURL(url)
|
||||
this.createdImageUrls.clear()
|
||||
this.draftAttachments.clear()
|
||||
this.imageUrls.clear()
|
||||
}, 'conversation attachment URL cache')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,13 +82,98 @@ export class ConversationService extends Service {
|
||||
* exists for caller choreography (the composer restores the draft on it).
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
* @param mode - queue after the current turn, or steer into it.
|
||||
* @param images - browser-owned temporary images promoted by the host during this call.
|
||||
*/
|
||||
async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
|
||||
async send(text: string, mode: 'queue' | 'steer', images: readonly File[] = []): Promise<void> {
|
||||
const session = this.scopedSession('send')
|
||||
const result = await session.prompt([{ type: 'text', text }], mode)
|
||||
const uploaded = await Promise.all(images.map(async file => ({
|
||||
type: 'image' as const,
|
||||
mediaType: imageMediaType(file.type),
|
||||
data: bytesToBase64(new Uint8Array(await file.arrayBuffer())),
|
||||
...(file.name === '' ? {} : { name: file.name }),
|
||||
})))
|
||||
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
|
||||
const result = await session.prompt(content, mode)
|
||||
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create runtime-only draft attachments and their object URLs.
|
||||
* @param files - browser-owned image files.
|
||||
* @returns ordered attachment descriptors whose ids may enter the chat store.
|
||||
*/
|
||||
createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
|
||||
return files.map((file) => {
|
||||
const attachment = new BrowserDraftAttachment(file)
|
||||
this.draftAttachments.set(attachment.id, attachment)
|
||||
this.createdImageUrls.add(attachment.previewUrl)
|
||||
return attachment
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ordered store ids to runtime-owned draft attachments.
|
||||
* @param ids - ordered ids from the chat store.
|
||||
* @returns attachments still available in this browser runtime.
|
||||
*/
|
||||
draftImages(ids: readonly string[]): readonly ComposerAttachment[] {
|
||||
const attachments: ComposerAttachment[] = []
|
||||
for (const id of ids) {
|
||||
const attachment = this.draftAttachments.get(id)
|
||||
if (attachment !== undefined) attachments.push(attachment)
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
/**
|
||||
* Release one draft attachment preview.
|
||||
* @param id - draft-local attachment id.
|
||||
*/
|
||||
releaseDraftImage(id: string): void {
|
||||
const attachment = this.draftAttachments.get(id)
|
||||
if (attachment === undefined) return
|
||||
this.draftAttachments.delete(id)
|
||||
this.createdImageUrls.delete(attachment.previewUrl)
|
||||
revokePreview(attachment.previewUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Release sent draft attachment previews.
|
||||
* @param attachments - successfully submitted attachments.
|
||||
*/
|
||||
releaseDraftImages(attachments: readonly ComposerAttachment[]): void {
|
||||
for (const attachment of attachments) this.releaseDraftImage(attachment.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and cache one session-authorized historical image as an object URL.
|
||||
* @param sessionId - session whose durable log grants the read.
|
||||
* @param attachment - immutable reference from that log.
|
||||
* @returns a browser URL for inline and original-size display.
|
||||
*/
|
||||
resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
|
||||
const key = `${sessionId}:${attachment.attachmentId}`
|
||||
const cached = this.imageUrls.get(key)
|
||||
if (cached !== undefined) return cached
|
||||
const pending = this.requireSessions().manager.get(sessionId).readAttachment(attachment.attachmentId)
|
||||
.then((result) => {
|
||||
if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
|
||||
if (typeof URL.createObjectURL !== 'function') {
|
||||
return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}`
|
||||
}
|
||||
const bytes = Uint8Array.from(result.value.data)
|
||||
const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType }))
|
||||
this.createdImageUrls.add(url)
|
||||
return url
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.imageUrls.delete(key)
|
||||
throw error
|
||||
})
|
||||
this.imageUrls.set(key, pending)
|
||||
return pending
|
||||
}
|
||||
|
||||
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
|
||||
async cancel(): Promise<void> {
|
||||
const session = this.scopedSession('cancel')
|
||||
@@ -132,7 +245,12 @@ export class ConversationService extends Service {
|
||||
* awaited through the RPC round trip).
|
||||
* @param opts - project directory, prompt text, and send mode.
|
||||
*/
|
||||
async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> {
|
||||
async startSession(opts: {
|
||||
cwd?: string
|
||||
text: string
|
||||
images?: readonly File[]
|
||||
mode: 'queue' | 'steer'
|
||||
}): Promise<void> {
|
||||
const sessions = this.requireSessions()
|
||||
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
|
||||
// The manager notifier flushes per microtask; one await guarantees the
|
||||
@@ -146,7 +264,7 @@ export class ConversationService extends Service {
|
||||
// global store and still binds this service to the scoped ctx.
|
||||
const scopedConversation = scoped.get('conversation')
|
||||
if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope')
|
||||
await scopedConversation.send(opts.text, opts.mode)
|
||||
await scopedConversation.send(opts.text, opts.mode, opts.images ?? [])
|
||||
}
|
||||
|
||||
/** Resolve the caller scope's Session or throw on root contexts. */
|
||||
@@ -173,3 +291,28 @@ function bumpViews(state: ViewsState): void {
|
||||
state.tick += 1
|
||||
for (const fn of [...state.listeners]) fn()
|
||||
}
|
||||
|
||||
function imageMediaType(value: string): ImageMediaType {
|
||||
switch (value) {
|
||||
case 'image/png':
|
||||
case 'image/jpeg':
|
||||
case 'image/webp':
|
||||
case 'image/gif':
|
||||
return value
|
||||
default:
|
||||
throw new Error(`不支持的图片格式:${value || '未知格式'}`)
|
||||
}
|
||||
}
|
||||
|
||||
function bytesToBase64(data: Uint8Array): string {
|
||||
let binary = ''
|
||||
const chunk = 0x8000
|
||||
for (let offset = 0; offset < data.length; offset += chunk) {
|
||||
binary += String.fromCharCode(...data.subarray(offset, offset + chunk))
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function revokePreview(url: string): void {
|
||||
if (url.startsWith('blob:')) URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
|
||||
// view id lives in the chat store's `view` field (per-session by store scope).
|
||||
|
||||
import { useMemo, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -36,7 +36,7 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useStore, actions,
|
||||
views, send, stop, openDetails, loadOlder, open,
|
||||
views, addImages, removeImage, draftImages, send, stop, openDetails, loadOlder, open,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const list = views.list()
|
||||
@@ -47,11 +47,22 @@ export function ConversationRoot({
|
||||
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const draft = useStore(s => s.draft)
|
||||
const imageIds = useStore(s => s.imageIds)
|
||||
const attachments = useMemo(() => draftImages(imageIds), [draftImages, imageIds])
|
||||
const running = useSession(s => s.running)
|
||||
const removed = useSession(s => s.removed)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const turns = useSession(s => countTurns(s))
|
||||
|
||||
// Browser File/object-URL values are intentionally runtime-only. A reload
|
||||
// may rehydrate ids whose objects no longer exist; prune those ids through
|
||||
// the declared store action after the first render.
|
||||
useEffect(() => {
|
||||
if (attachments.length !== imageIds.length) {
|
||||
actions.pruneImages(attachments.map(attachment => attachment.id))
|
||||
}
|
||||
}, [actions, attachments, imageIds])
|
||||
|
||||
const error: InputBarError | null = promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
|
||||
@@ -128,12 +139,15 @@ export function ConversationRoot({
|
||||
|
||||
<InputBar
|
||||
draft={draft}
|
||||
attachments={attachments}
|
||||
running={running}
|
||||
disabled={removed}
|
||||
error={error}
|
||||
variant="composer"
|
||||
onDraftChange={actions.setDraft}
|
||||
onSend={(mode) => { send(draft, mode) }}
|
||||
onAddImages={addImages}
|
||||
onRemoveAttachment={removeImage}
|
||||
onSend={(mode) => { send(draft, attachments, mode) }}
|
||||
onStop={stop}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
// §6) plus a free-form new-directory input; submit runs the startSession
|
||||
// chain (create → open → send) in one service call.
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { EmptyStateSlotProps } from '../contract/slots.ts'
|
||||
import type { ComposerAttachment, EmptyStateSlotProps } from '../contract/slots.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './EmptyState.module.css'
|
||||
@@ -36,6 +36,9 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
// Local viewing state: the empty state owns no session, so its draft is
|
||||
// ephemeral by design (drafts are keyed by session id; there is none yet).
|
||||
const [draft, setDraft] = useState('')
|
||||
const [attachments, setAttachments] = useState<readonly ComposerAttachment[]>([])
|
||||
const attachmentsRef = useRef(attachments)
|
||||
attachmentsRef.current = attachments
|
||||
const [cwd, setCwd] = useState<string>('')
|
||||
const [custom, setCustom] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
@@ -44,11 +47,16 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
const submit = (mode: 'queue' | 'steer'): void => {
|
||||
const text = draft.trim()
|
||||
/* v8 ignore next -- defensive: InputBar disables send while empty. */
|
||||
if (text === '' || sending) return
|
||||
if ((text === '' && attachments.length === 0) || sending) return
|
||||
setSending(true)
|
||||
setError(null)
|
||||
const chosen = cwd.trim()
|
||||
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
|
||||
startSession({
|
||||
text,
|
||||
...(attachments.length === 0 ? {} : { images: attachments.map(item => item.file) }),
|
||||
mode,
|
||||
...(chosen === '' ? {} : { cwd: chosen }),
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
// The empty state survives failure with the draft intact (no session
|
||||
// exists to carry promptError; this is the only local error surface).
|
||||
@@ -58,6 +66,24 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
// Success needs no cleanup: the session selection swaps this slot out for the session body.
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const attachment of attachmentsRef.current) URL.revokeObjectURL(attachment.previewUrl)
|
||||
}, [])
|
||||
|
||||
const addImages = (files: readonly File[]): void => {
|
||||
setAttachments(current => [...current, ...files.map(file => ({
|
||||
id: crypto.randomUUID(), file, previewUrl: URL.createObjectURL(file),
|
||||
}))])
|
||||
}
|
||||
|
||||
const removeImage = (id: string): void => {
|
||||
setAttachments((current) => {
|
||||
const removed = current.find(item => item.id === id)
|
||||
if (removed !== undefined) URL.revokeObjectURL(removed.previewUrl)
|
||||
return current.filter(item => item.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
const picker = (
|
||||
<div className={css.picker}>
|
||||
{custom
|
||||
@@ -102,6 +128,7 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
</div>
|
||||
<InputBar
|
||||
draft={draft}
|
||||
attachments={attachments}
|
||||
running={false}
|
||||
disabled={sending}
|
||||
error={error}
|
||||
@@ -109,6 +136,8 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
placeholder="Message to run task, plan and build"
|
||||
accessory={picker}
|
||||
onDraftChange={setDraft}
|
||||
onAddImages={addImages}
|
||||
onRemoveAttachment={removeImage}
|
||||
onSend={submit}
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 40px;
|
||||
background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent);
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: min(100%, 1600px);
|
||||
max-height: calc(100vh - 80px);
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.close {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import css from './ImageLightbox.module.css'
|
||||
|
||||
/** Document-level original-image preview opened by an explicit double-click. */
|
||||
export function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose(): void }) {
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null)
|
||||
const restoreRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
closeRef.current?.focus()
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
restoreRef.current?.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css.backdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="原图预览"
|
||||
onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}
|
||||
>
|
||||
<img className={css.image} src={src} alt={alt} />
|
||||
<button ref={closeRef} type="button" className={css.close} aria-label="关闭原图预览" onClick={onClose}>×</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* figma Input 34:11458: 12px between the text area and the button row. */
|
||||
@@ -49,6 +50,25 @@
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.dragActive {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2);
|
||||
}
|
||||
|
||||
.dropHint {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary));
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* New-session state rounds up (figma: r24 and a taller box). */
|
||||
.hero .card {
|
||||
border-radius: 24px;
|
||||
@@ -61,6 +81,57 @@
|
||||
padding: 10px 12px 0;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px 12px 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
position: relative;
|
||||
flex: 0 0 72px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dsw-specific-input-major);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-specific-input-major);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height
|
||||
(min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea
|
||||
MUST share font, line-height, padding and wrapping rules or heights diverge. */
|
||||
|
||||
@@ -5,11 +5,19 @@
|
||||
// LOCKS the input: textarea disabled with the draft visible, stop is the only
|
||||
// action; the turn ending re-enables and refocuses.
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { ComposerAttachment } from '../contract/slots.ts'
|
||||
import { ImageLightbox } from './ImageLightbox.tsx'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
|
||||
|
||||
function supportedImages(files: Iterable<File>): File[] {
|
||||
return [...files].filter(file => IMAGE_MEDIA_TYPES.has(file.type))
|
||||
}
|
||||
|
||||
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
|
||||
export interface InputBarError {
|
||||
op: 'send' | 'stop'
|
||||
@@ -18,6 +26,7 @@ export interface InputBarError {
|
||||
|
||||
export interface InputBarProps {
|
||||
draft: string
|
||||
attachments?: readonly ComposerAttachment[]
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
error: InputBarError | null
|
||||
@@ -27,15 +36,22 @@ export interface InputBarProps {
|
||||
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
|
||||
accessory?: ReactNode
|
||||
onDraftChange: (text: string) => void
|
||||
onAddImages?: (files: readonly File[]) => void
|
||||
onRemoveAttachment?: (id: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
export function InputBar({
|
||||
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
|
||||
draft, attachments = [], running, disabled, error, variant, placeholder, accessory,
|
||||
onDraftChange, onAddImages = () => {}, onRemoveAttachment = () => {}, onSend, onStop,
|
||||
}: InputBarProps) {
|
||||
const empty = draft.trim() === ''
|
||||
const empty = draft.trim() === '' && attachments.length === 0
|
||||
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [dropError, setDropError] = useState<string | null>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const dragDepthRef = useRef(0)
|
||||
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
|
||||
// clearing is deferred one tick because Safari delivers the closing keydown AFTER compositionend.
|
||||
const composingRef = useRef(false)
|
||||
@@ -72,6 +88,56 @@ export function InputBar({
|
||||
if (!empty && !locked) onSend('queue')
|
||||
}
|
||||
|
||||
const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>): void => {
|
||||
const files = [...event.clipboardData.items]
|
||||
.filter(item => item.kind === 'file' && IMAGE_MEDIA_TYPES.has(item.type))
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
if (files.length === 0) return
|
||||
event.preventDefault()
|
||||
setDropError(null)
|
||||
onAddImages(files)
|
||||
}
|
||||
|
||||
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
if (locked) return
|
||||
dragDepthRef.current += 1
|
||||
setDropError(null)
|
||||
setDragActive(true)
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = locked ? 'none' : 'copy'
|
||||
}
|
||||
|
||||
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files') || locked) return
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
|
||||
if (dragDepthRef.current === 0) setDragActive(false)
|
||||
}
|
||||
|
||||
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (!event.dataTransfer.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
dragDepthRef.current = 0
|
||||
setDragActive(false)
|
||||
if (locked) return
|
||||
const dropped = [...event.dataTransfer.files]
|
||||
const images = supportedImages(dropped)
|
||||
if (images.length === 0) {
|
||||
setDropError('暂仅支持 PNG、JPEG、WebP 和 GIF 图片')
|
||||
return
|
||||
}
|
||||
setDropError(images.length === dropped.length ? null : '已忽略不受支持的非图片文件')
|
||||
onAddImages(images)
|
||||
}
|
||||
|
||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||
|
||||
// Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly.
|
||||
const keepFocus = (e: MouseEvent<HTMLButtonElement>): void => {
|
||||
e.preventDefault()
|
||||
@@ -95,8 +161,38 @@ export function InputBar({
|
||||
{error.op === 'stop' ? '停止失败' : '发送失败'}:{error.message}
|
||||
</div>
|
||||
)}
|
||||
<div className={css.card}>
|
||||
{dropError !== null && <div className={css.error}>{dropError}</div>}
|
||||
<div
|
||||
className={clsx(css.card, dragActive && css.dragActive)}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{dragActive && <div className={css.dropHint} role="status">松开以添加图片</div>}
|
||||
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
|
||||
{attachments.length > 0 && (
|
||||
<div className={css.attachments} aria-label="待发送图片">
|
||||
{attachments.map(attachment => (
|
||||
<div key={attachment.id} className={css.attachment}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.thumbnail}
|
||||
title="双击查看原图"
|
||||
onDoubleClick={() => { setPreview(attachment) }}
|
||||
>
|
||||
<img src={attachment.previewUrl} alt={attachment.file.name || '待发送图片'} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.remove}
|
||||
aria-label={`移除图片 ${attachment.file.name || ''}`}
|
||||
onClick={() => { onRemoveAttachment(attachment.id) }}
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
|
||||
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
|
||||
rows by '\n' cannot see soft wraps. */}
|
||||
@@ -110,6 +206,7 @@ export function InputBar({
|
||||
rows={2}
|
||||
onChange={(e) => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={onPaste}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
/>
|
||||
@@ -137,6 +234,7 @@ export function InputBar({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{preview !== null && <ImageLightbox src={preview.previewUrl} alt={preview.file.name || '原图'} onClose={closePreview} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,11 @@ import type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.t
|
||||
type ChatActions = {
|
||||
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
|
||||
setDraft: (draft: ChatStoreState, text: string) => void
|
||||
addImages: (draft: ChatStoreState, ids: readonly string[]) => void
|
||||
removeImage: (draft: ChatStoreState, id: string) => void
|
||||
pruneImages: (draft: ChatStoreState, available: readonly string[]) => void
|
||||
clearDraft: (draft: ChatStoreState) => void
|
||||
restoreDraft: (draft: ChatStoreState, text: string) => void
|
||||
restoreDraft: (draft: ChatStoreState, text: string, imageIds: readonly string[]) => void
|
||||
setView: (draft: ChatStoreState, view: ViewId) => void
|
||||
}
|
||||
|
||||
@@ -37,15 +40,30 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
|
||||
// Anchored to the contract shape: views consume the store through
|
||||
// ConvViewProps' SnapshotSelectorHook<ChatStoreState>, so init and the
|
||||
// contract cannot drift.
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', imageIds: [], view: null }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
select: (d, target: SelectionTarget | null) => { d.selection = target },
|
||||
setDraft: (d, text: string) => { d.draft = text },
|
||||
clearDraft: (d) => { d.draft = '' },
|
||||
// Optimistic-send failure restore: only when the user typed nothing new
|
||||
// since the clear (send choreography lives in the inject factory).
|
||||
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
|
||||
addImages: (d, ids: readonly string[]) => { d.imageIds.push(...ids) },
|
||||
removeImage: (d, id: string) => {
|
||||
d.imageIds = d.imageIds.filter(candidate => candidate !== id)
|
||||
},
|
||||
pruneImages: (d, available: readonly string[]) => {
|
||||
const keep = new Set(available)
|
||||
d.imageIds = d.imageIds.filter(id => keep.has(id))
|
||||
},
|
||||
clearDraft: (d) => {
|
||||
d.draft = ''
|
||||
d.imageIds = []
|
||||
},
|
||||
// Optimistic-send failure restore keeps any newer typing/images while
|
||||
// restoring the submitted draft material that disappeared on clear.
|
||||
restoreDraft: (d, text: string, imageIds: readonly string[]) => {
|
||||
if (d.draft === '') d.draft = text
|
||||
const current = new Set(d.imageIds)
|
||||
d.imageIds = [...imageIds.filter(id => !current.has(id)), ...d.imageIds]
|
||||
},
|
||||
setView: (d, view: ViewId) => { d.view = view },
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user