Merge remote-tracking branch 'origin/master' into fix/stats

This commit is contained in:
07akioni
2026-08-12 21:22:05 +08:00
465 changed files with 11350 additions and 1149 deletions

View File

@@ -259,6 +259,7 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (): ConversationSessionHeaderInjected => ({
@@ -313,9 +314,9 @@ export function apply(ctx: Context): void {
return null
} catch (error: unknown) {
if (error instanceof UnsupportedImageMediaTypeError) {
return t('image.unsupportedType', {
type: error.mediaType || t('image.unknownType'),
})
// Positive copy: the supported list is fixed in imageMediaType,
// and naming it beats echoing the rejected MIME type back.
return t('image.unsupportedType')
}
return error instanceof Error ? error.message : String(error)
}

View File

@@ -10,6 +10,7 @@
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
import { memo, useMemo } from 'react'
import type { ReactNode } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -48,34 +49,60 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
const rendered: ReactNode[] = []
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i]
if (block === undefined) continue
switch (block.kind) {
case 'text':
rendered.push(
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>,
)
break
case 'reasoning':
rendered.push(<ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />)
break
case 'image': {
// Consecutive image blocks share one gallery so several images tile
// into rows instead of each opening a one-image group of its own.
// Keyed by the group's FIRST block index: a streaming append that
// extends the group then only grows `images` instead of remounting
// the gallery under a shifted key.
const start = i
const group = [block]
while (i + 1 < blocks.length) {
const next = blocks[i + 1]
if (next === undefined || next.kind !== 'image') break
group.push(next)
i += 1
}
rendered.push(<ImageGallery key={start} images={group} load={imageLoader} align="start" labels={messageImageLabels(t)} />)
break
}
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call':
break
default:
rendered.push(
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>,
)
}
}
return (
<div className={css.root} data-streaming={streaming || undefined}>
<div className={css.body}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return (
<MarkdownText
key={i}
text={block.text}
streaming={streaming}
codeLabels={codeLabels}
fileMentions={mentions}
/>
)
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
case 'image': return <ImageGallery key={i} images={[block]} load={imageLoader} align="start" labels={messageImageLabels(t)} />
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return (
<JsonBlock
key={i}
label={t('message.unknownBlock')}
payload={block.block}
truncatedLabel={total => t('json.truncated', { total })}
/>
)
}
})}
{rendered}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
</div>

View File

@@ -1,7 +1,7 @@
// Shared IconActions chrome for user and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { useCallback, useEffect, useId, useRef, useState } from 'react'
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
import {
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -29,6 +29,11 @@ export interface MessageIconActionsProps {
branchUnavailable?: boolean | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
/**
* Slot-rendered actions owned by independent plugins, placed between the
* built-in copy and branch controls.
*/
extraActions?: ReactNode
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -39,7 +44,8 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t,
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className,
extraActions, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
@@ -109,6 +115,7 @@ export function MessageIconActions({
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
</button>
</Tooltip>
{extraActions}
{onBranch !== undefined && (
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}

View File

@@ -244,6 +244,12 @@
font: var(--dsw-font-markdown-code-block-small);
}
.maxTokensTitle {
margin-right: 6px;
color: var(--dsw-alias-state-warn-primary);
font-weight: 600;
}
@keyframes retry-shimmer {
from {
background-position: 100% 50%;

View File

@@ -130,6 +130,21 @@ function TurnErrorItem({ node, t }: {
)
}
/** Persistent, turn-positioned notice for a turn ended at the output-token cap. */
function TurnMaxTokensItem({ t }: {
t: ChatViewSlotProps['t']
}) {
return (
<div className={css.turnErrorRow} role="status">
<StateDot state="warning" className={css.turnErrorDot} />
<div className={css.turnErrorCopy}>
<span className={css.maxTokensTitle}>{t('message.maxTokens')}</span>
<span className={css.turnErrorMessage}>{t('message.maxTokens.hint')}</span>
</div>
</div>
)
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -272,6 +287,11 @@ export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: Ch
return <TurnErrorItem node={node.data} t={t} />
})
/** Max-tokens turn-end notice keyed Chat renderer. */
export const TurnMaxTokensNodeView = memo(function TurnMaxTokensNodeView({ t }: ChatNodeViewProps<'turn-max-tokens'>) {
return <TurnMaxTokensItem t={t} />
})
/** Explicit unknown-surface keyed Chat renderer. */
export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) {
const data = node.data

View File

@@ -5,11 +5,12 @@ import { MessageIconActions } from './MessageIconActions.tsx'
import { assistantText } from './turn-assistant.ts'
import css from './TurnTailNodeView.module.css'
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'>
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
& PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'>
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
export const TurnTailNodeView = memo(function TurnTailNodeView({
node, openFile, forkAt, renderSlotChain, t, useSession,
node, openFile, forkAt, renderSlot, renderSlotChain, t, useSession,
}: TurnTailNodeViewProps) {
const data = node.data
const hasLaterChatNode = useSession(snapshot =>
@@ -25,6 +26,12 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
const runMs = turn.start === undefined || turn.end === undefined
? undefined
: Math.max(0, turn.end.time - turn.start.time)
// Interruption-frozen partials carry no messageId, so they address no
// durable message and contribute no per-message actions.
const messageId = closing.finalNode.messageId
const assistantActions = messageId === undefined
? null
: renderSlot('conversation.chat.assistant-actions', { messageId })
return (
<div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
{tail}
@@ -38,6 +45,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
onBranch={() => { forkAt(closing.finalNode.seq) }}
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
extraActions={assistantActions}
t={t}
/>
</div>

View File

@@ -4,7 +4,7 @@ import { AssistantNodeView } from './AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
import {
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
UnknownNodeView, UserMessageNodeView,
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
} from './MessageItem.tsx'
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
@@ -35,11 +35,16 @@ export function registerChatNodeRenderers(ctx: Context): void {
{ name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
name: 'conversation.chat.node',
key: 'turn-tail',
locale: NS,
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
children: {
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
},
}, TurnTailNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView))

View File

@@ -11,6 +11,7 @@ import type {
TurnLocation, WorkspaceId,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
import type {
@@ -44,6 +45,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* context that precedes interactive actions.
*/
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* Right-aligned Session utilities kept outside the title-adjacent action
* group, so an optional utility cannot reorder session context or lineage.
*/
'conversation.session.header.utilities': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
@@ -77,6 +83,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* only to return null; an all-declined chain renders nothing.
*/
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
/**
* Action strip attached to one finalized assistant message, rendered
* inside that message's IconActions row. The chat entry owns the render
* site and passes the addressed message identity; contributors add
* per-message actions without importing the conversation implementation.
* Entries render by ascending `order`.
*/
'conversation.chat.assistant-actions': {
kind: 'list'
scope: 'session'
owner: AssistantActionOwnerProps
}
/** Selected Tool call output inside the details panel. */
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
/**
@@ -253,6 +271,16 @@ export interface TurnTailOwnerProps {
openFile: (path: string) => void
}
/**
* Owner currency of the assistant-message action strip: the durable identity
* of the one finalized message the contributed actions address. Only finalized
* messages reach this slot, so the id is always present.
*/
export interface AssistantActionOwnerProps {
/** Stable identity carried from the `assistant/message` event. */
messageId: MessageId
}
/** Hook constrained to business data published on the current Chat Node's Turn. */
export type UseChatNodeTurnData = <Key extends Extract<keyof ConversationTurnDataMap, string>>(
key: Key,
@@ -502,7 +530,7 @@ export type ConversationSessionSlotProps =
/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */
export type ConversationSessionHeaderSlotProps =
PropsRuntime<'conversation.session.header'>
& PropsRenderSlots<'conversation.session.header.actions'>
& PropsRenderSlots<'conversation.session.header.actions' | 'conversation.session.header.utilities'>
& PropsStore<ChatStore>
& ConversationSessionHeaderInjected
& PropsLocale<'conversation'>

View File

@@ -152,6 +152,7 @@ function finalNode(
return {
kind: 'assistant',
seq: event.seq,
messageId: event.data.message.id,
time: event.time,
turn: state.turn,
step: state.step,

View File

@@ -165,6 +165,7 @@ function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
case 'command':
case 'compaction':
case 'turn-error':
case 'turn-max-tokens':
case 'unknown':
return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null }
case 'assistant-step': {

View File

@@ -7,11 +7,14 @@ import type {
/**
* Relative positions in one durable event's seq neighborhood: interrupted
* Assistant, its follow-up Nodes, then follow-ups to an ordinary final.
* Assistant, its follow-up Nodes, then follow-ups to an ordinary final. The
* max-tokens notice sits between a closing Assistant and the turn-tail so the
* tail stays the turn's last node and keeps its branch action enabled.
*/
export const CHAT_SYNTHETIC_SEQ_OFFSETS = {
interruptedAssistant: -0.9,
interruptedFollowup: -0.8,
maxTokensNotice: 0.05,
finalizedFollowup: 0.1,
} as const

View File

@@ -9,6 +9,7 @@ import { registerMessageConversationNode } from './message.ts'
import { registerRetryConversationNode } from './retry.ts'
import { registerToolConversationNode } from './tool.ts'
import { registerTurnErrorConversationNode } from './turn-error.ts'
import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts'
import { registerTurnTailConversationNode } from './turn-tail.ts'
/**
@@ -24,6 +25,7 @@ export function registerConversationNodes(ctx: Context): void {
registerCompactionConversationNode(ctx)
registerRetryConversationNode(ctx)
registerTurnErrorConversationNode(ctx)
registerTurnMaxTokensConversationNode(ctx)
registerTurnTailConversationNode(ctx)
registerUnknownConversationFallback(ctx)
registerChatConversationView(ctx)

View File

@@ -0,0 +1,82 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ChatNodeDataMap {
/** Turn ended by the per-request output-token cap. */
'turn-max-tokens': TurnMaxTokensNode
}
}
interface TurnMaxTokensState {
readonly turn: number
readonly seq: number
readonly time: number
}
function lastStep(context: ConversationNodeContext<TurnMaxTokensState>): number {
const location = context.start?.location ?? context.matches[0]?.location
if (location?.kind !== 'turn' && location?.kind !== 'step') return 0
return location.turn.steps.at(-1)?.step ?? 0
}
/**
* Anchor the notice between the closing Assistant and the turn-tail so the
* tail stays the turn's last Chat node and keeps its branch action enabled.
* Without a closing text Assistant there is no branch action to protect, and
* the turn/end seq keeps the notice at the truncation point.
*/
function noticeAnchor(context: ConversationNodeContext<TurnMaxTokensState>, seq: number): number {
const location = context.start?.location ?? context.matches[0]?.location
if (location?.kind !== 'turn' && location?.kind !== 'step') return seq
const closing = location.turn.data.get('turn-tail')?.closing
return closing === null || closing === undefined
? seq
: closing.finalNode.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.maxTokensNotice
}
function stateFrom(match: ConversationMatch): TurnMaxTokensState | undefined {
if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'max-tokens') return undefined
return { turn: match.event.data.turn, seq: match.event.seq, time: match.event.time }
}
/** Notice Definition for a turn the provider ended at its output-token cap. */
export const turnMaxTokensDefinition: ConversationNodeDefinition<TurnMaxTokensState> = {
kind: 'turn-max-tokens',
target: 'chat',
match: (event) => {
if (event.type === 'turn/end' && event.data.reason.kind === 'max-tokens') {
return { id: String(event.data.turn), role: 'start' }
}
return null
},
start: (_context, match) => {
const state = stateFrom(match)
if (state === undefined) throw new Error('turn-max-tokens start requires a max-tokens turn/end')
return state
},
update: context => context.state,
buildViewNode: (context) => {
const state = context.state
if (state === undefined) return null
const node: TurnMaxTokensNode = {
kind: 'turn-max-tokens',
seq: state.seq,
time: state.time,
turn: state.turn,
step: lastStep(context),
}
return chatNode(context, 'turn-max-tokens', noticeAnchor(context, state.seq), node)
},
}
/**
* Register the max-tokens turn-end notice contribution.
* @param ctx - owning UI Conversation context.
*/
export function registerTurnMaxTokensConversationNode(ctx: Context): void {
ctx.conversationEvents.register(turnMaxTokensDefinition)
}

View File

@@ -3,11 +3,60 @@
* application state; owners resolve every string). */
import type {
AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels,
AttachmentRailLabels, DropOverlayLabels, ImageLightboxLabels, MessageImageLabels,
} from '@deepseek-ai/dsh-client-ui-attachment'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationKey } from './locales.ts'
/**
* Byte count as user-facing megabytes (`10MB`, `2.5MB`).
* @param bytes - the byte count.
* @returns the rounded megabyte text.
*/
export function imageSizeText(bytes: number): string {
const mb = bytes / (1024 * 1024)
return `${Number.isInteger(mb) ? String(mb) : mb.toFixed(1)}MB`
}
/**
* Product copy for a host attachment rejection (the `attachment-error`
* `details.reason`). User-solvable reasons name the limit and the way out;
* reasons the user cannot act on fold into one send-failed line carrying the
* reason code for a bug report.
* @param t - the conversation-namespace translate.
* @param reason - the wire `details.reason` code.
* @param limits - projected limits interpolated into count/size copy, when known.
* @returns the banner text.
*/
export function attachmentErrorText(
t: Translate<ConversationKey>,
reason: string,
limits?: ImageAttachmentLimits,
): string {
switch (reason) {
case 'MODEL_DOES_NOT_SUPPORT_IMAGES': return t('image.modelUnsupported')
case 'SUBAGENT_IMAGE_UNSUPPORTED': return t('image.subagentUnsupported')
case 'IMAGE_TOO_MANY_PIXELS': return t('image.tooManyPixels')
// Undecodable bytes or a declared type its bytes contradict: solvable by
// replacing or re-exporting the file, so it reads as a format problem.
case 'INVALID_IMAGE':
case 'IMAGE_TYPE_MISMATCH':
return t('image.unsupportedType')
case 'TOO_MANY_IMAGES':
if (limits !== undefined) return t('image.tooMany', { count: limits.maxImagesPerMessage })
break
case 'IMAGE_TOO_LARGE':
if (limits !== undefined) return t('image.fileTooLarge', { size: imageSizeText(limits.maxImageBytes) })
break
case 'IMAGES_TOO_LARGE':
if (limits !== undefined) return t('image.totalTooLarge', { size: imageSizeText(limits.maxMessageImageBytes) })
break
default: break
}
return t('image.sendFailed', { reason })
}
/**
* Resolve the original-image lightbox strings.
* @param t - the conversation-namespace translate.
@@ -33,6 +82,25 @@ export function messageImageLabels(t: Translate<ConversationKey>): MessageImageL
}
}
/**
* Resolve the full-page drop overlay strings.
* @param t - the conversation-namespace translate.
* @param accepting - whether drops are currently accepted.
* @param limits - per-message limits for the desc line, when known.
* @returns the overlay title, with the limits desc while accepting.
*/
export function dropOverlayLabels(
t: Translate<ConversationKey>,
accepting: boolean,
limits?: { count: number; size: string },
): DropOverlayLabels {
if (!accepting) return { title: t('image.dropBlocked') }
return {
title: t('image.dropTitle'),
desc: limits === undefined ? undefined : t('image.dropDesc', { count: limits.count, size: limits.size }),
}
}
/**
* Resolve the composer draft-image rail strings.
* @param t - the conversation-namespace translate.

View File

@@ -11,6 +11,7 @@ export type {} from './conversation-nodes/message.ts'
export type {} from './conversation-nodes/retry.ts'
export type {} from './conversation-nodes/tool.ts'
export type {} from './conversation-nodes/turn-error.ts'
export type {} from './conversation-nodes/turn-max-tokens.ts'
export type {} from './conversation-nodes/turn-tail.ts'
export { apply, inject } from './apply.ts'

View File

@@ -25,7 +25,9 @@ export const zh = {
'input.send': '发送消息',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
'input.accessMode': '访问模式,当前:{name}',
'image.dropHint': '松开以添加图片',
'image.dropTitle': '图片拖动到此处即可添加',
'image.dropDesc': '最多 {count} 张,每张 {size}',
'image.dropBlocked': '当前无法添加图片',
'image.pending': '待发送图片',
'image.openOriginal': '查看原图',
'image.openOriginalLabel': '{label},点击查看原图',
@@ -39,8 +41,14 @@ export const zh = {
'image.preview': '原图预览',
'image.closePreview': '关闭原图预览',
'image.serviceUnavailable': '图片读取服务不可用',
'image.unsupportedType': '不支持的图片格式:{type}',
'image.unknownType': '未知格式',
'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片',
'image.tooMany': '一条消息最多添加 {count} 张图片',
'image.fileTooLarge': '单张图片不能超过 {size}',
'image.totalTooLarge': '图片总大小超过 {size},请移除部分图片',
'image.tooManyPixels': '图片分辨率过大,请压缩后重试',
'image.modelUnsupported': '当前模型不支持图片,请切换支持图片的模型',
'image.subagentUnsupported': '子智能体会话暂不支持图片',
'image.sendFailed': '图片发送失败({reason}),请重新添加图片后再试',
'context.aria': '上下文已用 {percent}',
'context.used': '上下文已用',
'context.system': '系统提示词',
@@ -114,6 +122,8 @@ export const zh = {
'message.retry.delay': '重试延迟:',
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'message.maxTokens': '已达到输出 token 上限',
'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
'message.ranFor': '用时 {duration}',
'message.ttft': '首 token {seconds}秒',
'message.tokensPerSecond': '{tps} tok/s',
@@ -184,7 +194,9 @@ export const en = {
'input.send': 'Send message',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
'input.accessMode': 'Access mode, current: {name}',
'image.dropHint': 'Drop to add images',
'image.dropTitle': 'Drag images here to add them',
'image.dropDesc': 'Up to {count} images, {size} each',
'image.dropBlocked': 'Images cannot be added right now',
'image.pending': 'Pending images',
'image.openOriginal': 'View original',
'image.openOriginalLabel': '{label}, click to view original',
@@ -198,8 +210,14 @@ export const en = {
'image.preview': 'Original image preview',
'image.closePreview': 'Close original image preview',
'image.serviceUnavailable': 'Image loading service unavailable',
'image.unsupportedType': 'Unsupported image format: {type}',
'image.unknownType': 'unknown format',
'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported',
'image.tooMany': 'A message can include up to {count} images',
'image.fileTooLarge': 'Each image must be smaller than {size}',
'image.totalTooLarge': 'Images exceed {size} in total; remove some and try again',
'image.tooManyPixels': 'Image resolution is too high; compress it and try again',
'image.modelUnsupported': 'The current model does not support images; switch to a model that does',
'image.subagentUnsupported': 'Subagent sessions do not support images yet',
'image.sendFailed': 'Sending images failed ({reason}); re-add them and try again',
'context.aria': '{percent} of context used',
'context.used': 'of context used',
'context.system': 'System prompt',
@@ -273,6 +291,8 @@ export const en = {
'message.retry.delay': 'Retry delay: ',
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'message.maxTokens': 'Output token limit reached',
'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',
'message.ranFor': 'Ran for {duration}',
'message.ttft': 'TTFT {seconds}s',
'message.tokensPerSecond': '{tps} tok/s',

View File

@@ -53,9 +53,18 @@
.titleRow {
display: flex;
align-items: center;
gap: 10px;
gap: 0;
min-height: 32px;
}
.titleCluster {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
}
.crumbs {
display: flex;
align-items: center;
@@ -111,6 +120,18 @@
gap: 8px;
}
.headerUtilities {
display: flex;
flex: none;
align-items: center;
gap: 8px;
margin-left: 20px;
}
.headerUtilities:empty {
display: none;
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
.tabs {
position: relative;

View File

@@ -69,27 +69,32 @@ export function ConversationSessionHeader({
{!hideChrome && (
<>
<div className={css.titleRow}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
<div className={css.titleCluster}>
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
</nav>
<div className={css.headerActions}>
{renderSlot('conversation.session.header.actions', {})}
</div>
</div>
<div className={css.headerUtilities}>
{renderSlot('conversation.session.header.utilities', {})}
</div>
</div>
{tabs.length > 1 && (

View File

@@ -115,25 +115,6 @@
background: var(--dsw-alias-state-business-primary);
}
.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;
}
.accessory {
display: flex;
align-items: center;

View File

@@ -7,23 +7,28 @@
* (running/removed/promptError) are self-selected via useSession. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import {
IconPlusOutline16, IconWarningOutline16, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
import { AttachmentRail, DropOverlay, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment'
import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
// composer reads a host-computed value; the domain owns the key).
import type {} from '@deepseek-ai/dsh-plan-mode/client'
// Type-only: the `goal` projection key merge (hint disambiguation).
import type {} from '@deepseek-ai/dsh-goal/client'
// The `imageLimits` projection key merge (intake pre-check) arrives with the
// wire types: apiproxy's sessions contract declares it, and client-runtime's
// api-remotes import already places it in every client program.
import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts'
import {
attachmentErrorText, attachmentRailLabels, dropOverlayLabels, imageSizeText, lightboxLabels,
} from '../image-labels.ts'
import { ContextMeter } from './ContextMeter.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
@@ -80,14 +85,22 @@ export function InputBar({
setToast({ seq: toastSeq.current, text })
}, [])
const dismissToast = useCallback(() => { setToast(null) }, [])
// The deployment's image-intake limits (absent while no attachment service
// is composed — the pre-check below then defers entirely to the host).
const imageLimits = useProjection('imageLimits')
// Prompt failures are ordinary failures (no create/attach transaction exists
// anymore): the toast announces promptError, the draft stays in the machine,
// and the user resubmits. A remount over a session whose machine still holds
// an unresolved promptError deliberately re-announces it once — the failure
// is still pending, and a transient banner is its only surface.
// is still pending, and a transient banner is its only surface. Attachment
// rejections show product copy keyed by the wire reason; other codes are
// developer-facing and keep the raw message plus code.
useEffect(() => {
if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast])
if (promptError === null) return
showToast(promptError.error.code === 'attachment-error'
? attachmentErrorText(t, promptError.error.details.reason, imageLimits)
: `${promptError.error.message} (${promptError.error.code})`)
}, [promptError, showToast, t, imageLimits])
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const cardRef = useRef<HTMLDivElement | null>(null)
const dragDepthRef = useRef(0)
@@ -384,10 +397,7 @@ export function InputBar({
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length > 0 && addImages !== undefined) {
const rejected = addImages(files)
if (rejected !== null) showToast(rejected)
}
if (files.length > 0) intakeImages(files)
const text = e.clipboardData.getData('text/plain')
if (text === '') {
if (files.length > 0) e.preventDefault()
@@ -406,38 +416,94 @@ export function InputBar({
keyboard.track(keyboard.snapshot.draft, caret)
}
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
if (locked || machineBusy || addImages === undefined) return
dragDepthRef.current += 1
setDragActive(true)
}
// Intake pre-check (DeepSeek Chat semantics): an addition that would break
// a projected limit is refused as a whole batch, announced immediately, and
// never enters the rail — no more submit-time failure rolling the rail
// back. The host enforces the same limits at submit for callers that bypass
// this composer.
const intakeImages = useCallback((files: readonly File[]): void => {
if (addImages === undefined || files.length === 0) return
const rejected = ((): string | null => {
if (imageLimits !== undefined) {
// Format precedes limits (DeepSeek Chat's filter order): a batch with
// a non-image must announce the format problem, not a count or size
// it could never pass anyway — addImages rejects it authoritatively.
if (files.some(file => !(imageLimits.mediaTypes as readonly string[]).includes(file.type))) {
return addImages(files)
}
if (attachments.length + files.length > imageLimits.maxImagesPerMessage) {
return t('image.tooMany', { count: imageLimits.maxImagesPerMessage })
}
if (files.some(file => file.size > imageLimits.maxImageBytes)) {
return t('image.fileTooLarge', { size: imageSizeText(imageLimits.maxImageBytes) })
}
const total = attachments.reduce((sum, attachment) => sum + attachment.file.size, 0)
+ files.reduce((sum, file) => sum + file.size, 0)
if (total > imageLimits.maxMessageImageBytes) {
return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) })
}
}
return addImages(files)
})()
if (rejected !== null) showToast(rejected)
}, [addImages, attachments, imageLimits, showToast, t])
const onDragOver = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy'
}
const onDragLeave = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) 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 || machineBusy || addImages === undefined) return
const dropped = [...event.dataTransfer.files]
if (dropped.length > 0) {
const rejected = addImages(dropped)
if (rejected !== null) showToast(rejected)
// Whole-page file-drop intake (DeepSeek Chat behavior): the listeners live
// on the document so a drop anywhere over the window adds images, not only
// over the composer card. Safe as document-level state: the composer-bar
// slot is `kind: 'single'`, so at most one bar is mounted to bind these.
// Text drags carry no 'Files' type and pass through untouched, keeping the
// native drop-text-into-textarea path. The overlay layer itself is
// pointer-inert, so it never disturbs the enter/leave count.
const canAcceptDrop = !locked && !machineBusy && addImages !== undefined
useEffect(() => {
const hasFiles = (event: globalThis.DragEvent): boolean =>
event.dataTransfer?.types.includes('Files') ?? false
const reset = (): void => {
dragDepthRef.current = 0
setDragActive(false)
}
}
const onDragEnter = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
event.preventDefault()
dragDepthRef.current += 1
setDragActive(true)
}
const onDragOver = (event: globalThis.DragEvent): void => {
if (!hasFiles(event) || event.dataTransfer === null) return
event.preventDefault()
event.dataTransfer.dropEffect = canAcceptDrop ? 'copy' : 'none'
}
const onDragLeave = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragActive(false)
// Leaving through the viewport edge does not balance the count on every
// engine; a page-root leave at the border means the drag left the window.
const leavingViewport = event.clientX <= 0 || event.clientY <= 0
|| event.clientX >= window.innerWidth || event.clientY >= window.innerHeight
if ((event.target === document.documentElement || event.target === document.body) && leavingViewport) reset()
}
const onDrop = (event: globalThis.DragEvent): void => {
if (!hasFiles(event)) return
event.preventDefault()
reset()
if (!canAcceptDrop) return
intakeImages([...(event.dataTransfer?.files ?? [])])
}
document.addEventListener('dragenter', onDragEnter)
document.addEventListener('dragover', onDragOver)
document.addEventListener('dragleave', onDragLeave)
document.addEventListener('drop', onDrop)
window.addEventListener('dragend', reset)
return () => {
document.removeEventListener('dragenter', onDragEnter)
document.removeEventListener('dragover', onDragOver)
document.removeEventListener('dragleave', onDragLeave)
document.removeEventListener('drop', onDrop)
window.removeEventListener('dragend', reset)
}
}, [canAcceptDrop, intakeImages])
const closePreview = useCallback(() => { setPreview(null) }, [])
@@ -573,6 +639,15 @@ export function InputBar({
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{dragActive && (
<DropOverlay
disabled={!canAcceptDrop}
labels={dropOverlayLabels(t, canAcceptDrop, imageLimits === undefined ? undefined : {
count: imageLimits.maxImagesPerMessage,
size: imageSizeText(imageLimits.maxImageBytes),
})}
/>
)}
{toast !== null && (
<Toast
key={toast.seq}
@@ -594,16 +669,11 @@ export function InputBar({
click's reopen (close-then-open flickers the chip's open echo). */}
<div
ref={cardRef}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger, dragActive && css.dragActive)}
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger)}
data-composer-card
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
onPointerDown={workspaceTrigger ? (e) => { e.stopPropagation() } : undefined}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{dragActive && <div className={css.dropHint} role="status">{t('image.dropHint')}</div>}
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{railItems.length > 0 && (