Files
deepseek-harness/packages/client/ui-conversation/src/client/contract/input.ts
T
Yichen Jiang faf62f4c1a Merge branch 'worktree/composer-image-preupload' into worktree/pr-6426-review-970262
# Conflicts:
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/input/facade.ts
#	packages/client/ui-conversation/src/client/input/hub.ts
2026-10-21 15:15:46 +08:00

400 lines
18 KiB
TypeScript

/**
* Frozen input-machine contract. Types
* only. Three-tier visibility: business packages see InputState via the
* InputZone currency; the scoped input events carry the mutation verbs; the
* conversation wiring layer alone sees the full SessionInput. The draft text
* and its reference chips live in the shell's Lexical editor; the machine
* here is the submit plane (phase, claim, attempt) alone.
*/
import type { Context } from '@deepseek-ai/cordis'
import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ArbitrateKey, ArbitrateOutcome, DraftQuote, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts'
import type { InputSubmitMode, MessageSubmission } from './composer-submission.ts'
/** Attachment payload passed to a claimed command submission. */
export type SubmitAttachment =
| {
readonly type: 'image'
readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
readonly data: string
readonly name?: string
}
| { readonly type: 'file'; readonly receiptId: string }
/** Command serialization result for one ordered attachment draft. */
export interface DraftAttachmentSerializationResult {
readonly attachments: readonly SubmitAttachment[]
}
/** Settled result of a command or default composer submission. */
export interface SubmitOutcome {
readonly kind: 'success' | 'error'
readonly text?: string
}
/** Command-mode credential supplied by one input-trigger source. */
export interface CommandClaim {
/** Catalog command name without the leading slash (the key of per-command composer copy such as `hint.*`). */
readonly name: string
/** Command spelling followed by its argument separator; the composer shows the spelling as a chip and inserts the separator after it. */
readonly token: string
readonly hint?: string
readonly attachments?: boolean
/**
* Submit the claimed command.
* @param args - command text after the claimed token.
* @param actx - current Session scope.
* @param attachments - serialized draft attachments accepted by the claim.
* @returns command settlement.
*/
submit(args: string, actx: Context, attachments: readonly SubmitAttachment[]): Promise<SubmitOutcome>
}
/** Result of trigger-source adjudication. */
export type PickOutcome =
| { readonly claim: CommandClaim }
| { readonly insert: ReferenceInsert }
| { readonly text: string; readonly continue?: boolean }
| 'handled'
| undefined
/** Scoped request to enter command mode. */
export interface BeginCommandRequest {
readonly claim: CommandClaim
readonly span: TokenSpan
}
/** Scoped request to insert a structured reference. */
export interface InsertReferenceRequest {
readonly reference: ReferenceInsert
readonly span: TokenSpan
}
/** Scoped request to consume a command token after business settlement. */
export interface ConsumeTokenRequest {
readonly guard:
| { readonly kind: 'span'; readonly span: TokenSpan }
| { readonly kind: 'bare-token'; readonly token: string }
}
/** Scoped request to insert ordinary completion text. */
export interface InsertTextRequest {
readonly text: string
readonly span: TokenSpan
readonly continue?: boolean
}
/** Trigger hit used to open one source programmatically. */
export interface InputTriggerHit {
readonly trigger: '/' | '@'
readonly query: string
readonly quoted: boolean
readonly position: 'leading' | 'inline'
readonly span: TokenSpan
}
/** Structural per-Session trigger provider consumed by the input shell. */
export interface InputTriggerController {
readonly launcher: ObservableSnapshot<string | null>
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
/** @param draft - current draft. @param caret - caret offset. @param guard - availability tier. @param draftRev - input revision. */
track(
draft: string,
caret: number,
guard: { readonly tier: 'plain' | 'claimed' | 'frozen' },
draftRev: number,
): void
/** @param key - intercepted key. @param composing - whether IME composition is active. @returns routing result. */
arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome
/** @returns whether Space applied a trigger result. */
onSpace(): boolean
/** @param source - reference source. @param ref - source-local id. @param signal - submit cancellation. @returns model text. */
serializeReference(source: string, ref: string, signal: AbortSignal): Promise<string>
/** @param line - trimmed draft. @param signal - submit cancellation. @param envelope - attachment count. @returns winning result. */
adjudicate(
line: string,
signal: AbortSignal,
envelope: { readonly attachments: number },
): Promise<PickOutcome>
/**
* Resolve the claim a persisted command chip names without acting on it.
* @param token - command spelling without its argument separator, e.g. '/goal'.
* @param signal - restore-attempt abort.
* @returns the claim, or undefined when no source claims the token; rejects when catalog warmup fails.
*/
resolveClaim(token: string, signal: AbortSignal): Promise<CommandClaim | undefined>
/**
* @param source - chip owner, or undefined for a text reference.
* @param reference - source id and glyph.
* @returns whether a preview opened.
*/
openReference(source: string | undefined, reference: Pick<ReferenceInsert, 'ref' | 'appearance'>): boolean
/** @param source - source name. @param hit - synthetic trigger hit. */
toggleSource(source: string, hit: InputTriggerHit): void
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Claim a command token for the scoped input machine.
* @param request - command claim and span.
* @mode bail
*/
'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
/**
* Insert a structured reference into the scoped input machine.
* @param request - reference and span.
* @mode bail
*/
'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
/**
* Consume a trigger token without inserting replacement content.
* @param request - token guard.
* @mode bail
*/
'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
/**
* Insert plain text into the scoped input machine.
* @param request - plain text and span.
* @mode bail
*/
'slash/input-insert-text'(request: InsertTextRequest): true | undefined
}
}
/** Browser-runtime identity of one unsent attachment draft. */
export type DraftAttachmentId = Branded<'DraftAttachmentId'>
/**
* The scoped-event application verbs: the hub's bail listeners call these,
* and the boolean answer IS the event's bail value (true ⟺ the editor
* applied the edit after phase and span guards).
*/
export interface InputTarget {
/** Replace the leading trigger span with a command chip and enter claimed (span-CAS'd). */
beginCommand(claim: CommandClaim, span: TokenSpan): boolean
/** Replace the trigger span with one reference chip (span-CAS'd). */
insertReference(ref: ReferenceInsert, span: TokenSpan): boolean
}
/** Per-session input facade owned by the conversation wiring layer. */
export interface SessionInput extends InputTarget {
/** Insert selected transcript text as an atomic quote. @param text - nonblank selection. @returns whether editing accepted it. */
insertQuote(text: string): boolean
/** Replace the whole draft with plain text, removing inline reference chips. */
setDraft(text: string): void
/** Append ordered browser-owned attachment ids; busy admission phases refuse. */
addAttachments(ids: readonly DraftAttachmentId[]): boolean
/** Remove one browser-owned attachment id; busy admission phases refuse. @returns whether the id was removed. */
removeAttachment(id: DraftAttachmentId): boolean
/** Drop ids whose browser-owned objects no longer exist. */
pruneAttachments(ids: readonly DraftAttachmentId[]): void
/**
* THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
* @param mode - delivery intent retained through asynchronous adjudication and serialization.
*/
submit(mode?: InputSubmitMode, source?: 'click' | 'enter'): void
/**
* Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here.
* Session-routed — resolving the facade via SessionInputResolver.for(actx) lands
* the notice on that session's composer, so a result arriving after a
* session switch still reaches its own session.
* @param level - severity tier.
* @param text - notice body.
*/
notify(level: 'info' | 'error', text: string): void
/**
* Return the keyboard to the composer with the caret it last held, for
* callers that took focus away from it (an overlay that held its own).
*/
focus(): void
/** Input state store (InputZone currency + decorations read here). */
readonly state: SnapshotStore<InputState>
}
/** Session-addressed access to the per-session input facade. */
export interface SessionInputResolver {
/** Resolve the facade for one session-scope ctx. */
for(actx: Context): SessionInput
/**
* Prepare the exact retained Session before navigation publishes it.
* @param binding - target Session generation, already retained by the caller.
* @param options - content to adopt and the handling of an existing draft.
* @returns applied, preserved, or blocked by a pending submission or disposal.
* @throws when binding is not the currently retained Session generation.
*/
requestDraftInitialization(binding: SessionBinding, options: DraftInitializationOptions): DraftInitializationResult
}
/** Initial text for a target Session; attachments remain under their existing owner. */
export interface DraftInitializationOptions {
readonly prompt?: string
/** `true` acts as `whenDraftExists: 'replace'`; ignored when `whenDraftExists` is set. */
readonly clearPreviousDraft?: boolean
/**
* Handling of a target that already has text or attachments: `replace` swaps the text and
* inline references for the prompt (or clears them without one), `append` adds the prompt
* as new paragraphs after the existing text, and `keep` leaves the draft unchanged. Omitted,
* it is `replace` when `clearPreviousDraft` is true and `keep` otherwise.
*/
readonly whenDraftExists?: 'replace' | 'append' | 'keep'
}
/** An initialization request never waits for a React mount or a remote catalog. */
export type DraftInitializationResult = 'applied' | 'preserved' | 'blocked'
/**
* The public input action face provided to every session-scope slot
* component: stable-identity callbacks, mirroring the
* useStore+actions convention. Command-style handles (arbitrate/space/
* paste/…) stay InputBar-private and never ride this face.
*/
export interface InputActions {
/** Insert one nonblank transcript quote. @param text - selected text. @returns whether editing accepted it. */
insertQuote(text: string): boolean
/** @returns a revision-guarded copy of the current editor selection. */
captureInsertion(): TokenSpan
/**
* Insert asynchronous text without replacing subsequent edits or reference chips.
* @param text - plain text to insert in one undo step.
* @param span - captured selection and draft revision.
* @returns false when the draft changed or submission locked the editor.
*/
insertText(text: string, span: TokenSpan): boolean
/** Replace the whole draft with plain text, removing inline reference chips. */
setDraft(text: string): void
/** Write the current semantic document through the mounted persistence callback. */
persistDraft(): void
/** Append ordered browser-owned attachment ids; busy admission phases refuse. */
addAttachments(ids: readonly DraftAttachmentId[]): boolean
/** Remove one browser-owned attachment id; busy admission phases refuse. */
removeAttachment(id: DraftAttachmentId): void
/** Drop ids whose browser-owned objects no longer exist. */
pruneAttachments(ids: readonly DraftAttachmentId[]): void
/** Enter submission (adjudication / claim transaction / default sink inside). */
submit(): void
}
/** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */
export interface InputNotice {
readonly level: 'info' | 'error'
readonly text: string
readonly seq: number
}
/** Guard union of the scoped consume-token event, checked by the shell. */
export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
/** Published input state (the currency; per-session). */
export interface InputState {
/** Clipboard-text projection of the editor document (chips expanded to their clipboard form). */
readonly draft: string
/** Ordered runtime-only attachment ids; browser objects stay in ConversationController. */
readonly attachmentIds: readonly DraftAttachmentId[]
/** Monotonic editor revision (span CAS compares against this). */
readonly draftRev: number
/**
* Submit plane: `adjudicating` while a `/` line is checked against the
* commands, `submitting` while a claimed command runs, and `sending` while
* an ordinary message waits for the Host to accept it. The editor and the
* attachments are read-only in all three.
*/
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' | 'sending'
/** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
readonly claim?: { readonly name: string; readonly token: string; readonly hint?: string; readonly attachments?: boolean }
/** Reference occurrence view of the editor's chips, sorted by offset. */
readonly occurrences: readonly Occurrence[]
/** Messages still waiting for their own turn. */
readonly queue: InboxState['next-turn']
}
/**
* One in-flight submission attempt: the ONLY id concept in the submit plane.
* Created on enter; carried by adjudicated/submit-settled/sink-settled
* events; stale attempts are dropped (anti-backwash). Command and message
* attempts hold the single frozen in-flight slot, so the draft they carry is
* the one the user sent. release/session teardown aborts the attempt, keeping
* every promise bounded.
*/
export interface SubmitAttempt {
readonly seq: number
readonly signal: AbortSignal
/** Clipboard-projection draft captured when the user sent. */
readonly draftSnapshot: string
/** Reference chips of the sent draft, captured with it. */
readonly occurrences: readonly Occurrence[]
/** Quotes of the sent draft, captured with it. */
readonly quotes: readonly DraftQuote[]
/** Attachments of the sent draft, captured with it, in order. */
readonly attachmentIds: readonly DraftAttachmentId[]
/** Default-message delivery intent retained while slash adjudication is pending. */
readonly mode: InputSubmitMode
/** Original message occurrence, retained across command arbitration. */
readonly submission?: MessageSubmission
}
/**
* Submit-machine input events (the machine's single write path). Text
* mutation lives in the editor; the machine only observes the draft through
* event payloads (leading command chip, enter snapshots, settlement decisions).
*/
export type InputEvent =
/** Editor content changed; carries the claim of the leading command chip, if any (zero effects). */
| { readonly type: 'draft-changed'; readonly command: CommandClaim | undefined }
/** The editor inserted a command chip: enter claimed. */
| { readonly type: 'claim'; readonly claim: CommandClaim }
/** Enter submission with the current clipboard projection, its reference chips, its quotes, and its attachments. */
| {
readonly type: 'enter'
readonly mode: InputSubmitMode
readonly draft: string
readonly occurrences: readonly Occurrence[]
readonly quotes: readonly DraftQuote[]
readonly attachmentIds: readonly DraftAttachmentId[]
readonly submission?: MessageSubmission
}
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
/** Settlement carries the leading command chip's claim, which the claim follows after a failure. */
| {
readonly type: 'submit-settled'
readonly attempt: SubmitAttempt
readonly ok: boolean
readonly command: CommandClaim | undefined
readonly outcome?: SubmitOutcome
readonly message?: string
}
/** Settlement of the message being sent: the Host accepted it, or refused it or failed. */
| { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
| { readonly type: 'release' }
/**
* Submit-machine output effects (executed by the SessionInput shell; the
* machine stays pure).
*/
export type InputEffect =
| { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
| { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
/** Send the attempt's frozen draft, references, and attachments as one ordinary message. */
| {
readonly type: 'default-sink'
readonly attempt: SubmitAttempt
readonly draft: string
readonly mode: InputSubmitMode
}
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }
/**
* Clear the committed draft in the editor and cut undo history. A string
* snapshot keeps a pure suffix typed during the Host round-trip (content
* appended after the sent snapshot survives; interleaved edits cannot be
* separated and clear whole); null clears unconditionally (an accepted
* message froze the editor, so nothing was typed after it).
*/
| { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null }