@anionex/dsh-vision-toolkit 0.1.40 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  /** Clipboard-only multi-image input for DSH Web. */
2
2
 
3
3
  import { useSyncExternalStore, type ReactNode } from 'react'
4
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
5
- import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
4
+ import type { Context as ClientContext } from '@deepseek-ai/cordis'
5
+ import type { DraftAttachmentId } from '@deepseek-ai/dsh-client-ui-conversation/client'
6
6
  import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
7
+ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
7
8
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
8
9
  import { readDisplayConfig } from './display-config.ts'
9
10
 
@@ -17,6 +18,47 @@ const MAX_BATCH_BYTES = 80 * 1024 * 1024
17
18
  /** A confirmed paste verdict older than this is unknown again, even while a refresh is in flight. */
18
19
  const VERDICT_MAX_AGE_MS = 15000
19
20
 
21
+ /**
22
+ * Count the draft attachment ids in an InputState snapshot across DSH
23
+ * generations. DSH 0.1.5 renamed the field to `attachmentIds` and widened its
24
+ * meaning from image-only ids to every draft attachment id; the older
25
+ * `imageIds` name survives only as a compatibility fallback for hosts that
26
+ * still publish it, and never crashes when it is absent.
27
+ * @param snapshot - the `input.state.getSnapshot()` value (either generation).
28
+ * @returns the draft attachment count, 0 when neither field is published.
29
+ */
30
+ function draftAttachmentCount(snapshot: unknown): number {
31
+ if (snapshot === null || typeof snapshot !== 'object') return 0
32
+ const row = snapshot as {
33
+ attachmentIds?: readonly DraftAttachmentId[]
34
+ imageIds?: readonly string[]
35
+ }
36
+ if (Array.isArray(row.attachmentIds)) return row.attachmentIds.length
37
+ return Array.isArray(row.imageIds) ? row.imageIds.length : 0
38
+ }
39
+
40
+ /**
41
+ * The Client Session registry face this controller reads. DSH 0.1.5 publishes
42
+ * it as `Context.sessions` from `@deepseek-ai/dsh-api-session-controller/client`,
43
+ * whose peer set spans the whole host package graph and therefore cannot be a
44
+ * peer of a Web-only plugin; only these two members are used, so the face is
45
+ * declared structurally instead of importing that package.
46
+ */
47
+ interface ClientSessionRegistry {
48
+ /** Resolve the Session-scoped Context; undefined once the Session has closed. */
49
+ scope(sessionId: never): ClientContext | undefined
50
+ /** Live Session list projection; `current` is the focused Session id. */
51
+ readonly list: { getSnapshot(): { readonly current?: string | undefined } }
52
+ }
53
+
54
+ /**
55
+ * @param ctx - the browser plugin Context.
56
+ * @returns the host's Session registry through the structural face above.
57
+ */
58
+ function sessionRegistry(ctx: ClientContext): ClientSessionRegistry {
59
+ return (ctx as unknown as { readonly sessions: ClientSessionRegistry }).sessions
60
+ }
61
+
20
62
  interface PasteRecord {
21
63
  ref: string
22
64
  file: File
@@ -58,6 +100,8 @@ interface PasteOccurrence {
58
100
  source: string
59
101
  ref: string
60
102
  offset: number
103
+ /** DSH rc.8+ stores the full @label text; older releases used one placeholder. */
104
+ length?: number
61
105
  label: string
62
106
  }
63
107
 
@@ -166,6 +210,10 @@ function pasteLabel(file: File, index: number): string {
166
210
  return file.name.trim() || `clipboard-image-${index + 1}`
167
211
  }
168
212
 
213
+ function occurrenceEnd(occurrence: PasteOccurrence): number {
214
+ return occurrence.offset + (occurrence.length ?? 1)
215
+ }
216
+
169
217
  /** Owns browser File objects until DSH serializes the corresponding text references. */
170
218
  export class PasteImageController {
171
219
  private readonly records = new Map<string, PasteRecord>()
@@ -216,7 +264,7 @@ export class PasteImageController {
216
264
  }
217
265
 
218
266
  private inputFor(sessionId: string) {
219
- const actx = this.ctx.sessions.scope(sessionId as never)
267
+ const actx = sessionRegistry(this.ctx).scope(sessionId as never)
220
268
  if (actx === undefined) throw new Error('Open a live session before pasting images')
221
269
  return this.ctx.conversation.input.for(actx)
222
270
  }
@@ -241,6 +289,7 @@ export class PasteImageController {
241
289
  if (before !== '' && !/\s$/u.test(before)) cursor = this.insertText(input, ' ', cursor)
242
290
  for (const [index, file] of files.entries()) {
243
291
  const ref = id()
292
+ const label = pasteLabel(file, index)
244
293
  const record: PasteRecord = { ref, file, batch, status: 'ready' }
245
294
  batch.records.push(record)
246
295
  this.records.set(ref, record)
@@ -248,11 +297,14 @@ export class PasteImageController {
248
297
  const accepted = input.insertReference({
249
298
  source: SOURCE,
250
299
  ref,
251
- label: pasteLabel(file, index),
252
- clipboardText: `[pasted image: ${pasteLabel(file, index)}]`,
300
+ label,
301
+ clipboardText: `[pasted image: ${label}]`,
253
302
  }, { start: cursor, end: cursor, draftRev: snapshot.draftRev })
254
303
  if (!accepted) throw new Error('The composer changed before pasted images could be inserted')
255
- cursor += 1
304
+ const inserted = input.state.getSnapshot().occurrences.find(occurrence =>
305
+ occurrence.source === SOURCE && occurrence.ref === ref)
306
+ if (inserted === undefined) throw new Error('The pasted image reference was not present after insertion')
307
+ cursor = occurrenceEnd(inserted)
256
308
  const hasNext = index + 1 < files.length
257
309
  const suffix = input.state.getSnapshot().draft.slice(cursor)
258
310
  if (hasNext || (suffix !== '' && !/^\s/u.test(suffix))) cursor = this.insertText(input, ' ', cursor)
@@ -472,7 +524,9 @@ export class PasteImageController {
472
524
  * Auto-switch flow: switch the Session to the image-input variant, announce
473
525
  * it, then replay the paste into the composer's native intake. A failed
474
526
  * switch, or an environment that cannot replay clipboard bytes, degrades to
475
- * the path takeover with the same files.
527
+ * the path takeover with the same files. The post-replay probe compares the
528
+ * draft attachment count before and after, so it observes any attachment the
529
+ * native intake admitted rather than images alone.
476
530
  * @param sessionId - the live Session id.
477
531
  * @param target - the composer textarea the paste landed on.
478
532
  * @param files - the captured image files.
@@ -501,10 +555,10 @@ export class PasteImageController {
501
555
  // Replaying lets the composer's own intake run (thumbnail, limits,
502
556
  // keyboard); if the environment cannot replay clipboard bytes, the
503
557
  // images still land as workspace paths.
504
- const before = input.state.getSnapshot().imageIds.length
558
+ const attachmentsBefore = draftAttachmentCount(input.state.getSnapshot())
505
559
  const replayed = this.replayPaste(target, files, text)
506
- const after = input.state.getSnapshot().imageIds.length
507
- if (!replayed || after <= before) {
560
+ const attachmentsAfter = draftAttachmentCount(input.state.getSnapshot())
561
+ if (!replayed || attachmentsAfter <= attachmentsBefore) {
508
562
  this.takeoverPaste(sessionId, target, files, text)
509
563
  }
510
564
  }
@@ -548,7 +602,7 @@ export class PasteImageController {
548
602
  const target = event.target
549
603
  if (!(target instanceof HTMLTextAreaElement) || target.closest('[data-composer-card]') === null) return false
550
604
 
551
- const sessionId = this.ctx.sessions.list.getSnapshot().current
605
+ const sessionId = sessionRegistry(this.ctx).list.getSnapshot().current
552
606
  if (sessionId === undefined) return false
553
607
  const modelLabel = currentModelLabel()
554
608
  this.refreshVerdict(sessionId, modelLabel)
@@ -594,7 +648,7 @@ export class PasteImageController {
594
648
  insertText: (text: string, span: { start: number; end: number; draftRev: number }) => boolean
595
649
  }).insertText('', {
596
650
  start: current.offset,
597
- end: current.offset + 1,
651
+ end: occurrenceEnd(current),
598
652
  draftRev: snapshot.draftRev,
599
653
  })
600
654
  if (!accepted) return
@@ -725,7 +779,7 @@ export function installPasteImages(ctx: ClientContext): void {
725
779
  const listener = (event: ClipboardEvent): void => { controller.handlePaste(event) }
726
780
  // A focus-time prefetch has the verdict ready before the first paste can land.
727
781
  const onFocusIn = (): void => {
728
- const sessionId = ctx.sessions.list.getSnapshot().current
782
+ const sessionId = sessionRegistry(ctx).list.getSnapshot().current
729
783
  if (sessionId !== undefined) controller.refreshVerdict(String(sessionId), currentModelLabel())
730
784
  }
731
785
  document.addEventListener('paste', listener, true)
package/src/exposure.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import type { Agent } from '@deepseek-ai/dsh-agent'
10
10
  import type { ContentBlock } from '@deepseek-ai/dsh-llm'
11
11
  import type { Session } from '@deepseek-ai/dsh-session'
12
+ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
12
13
  import { defineTool, type ToolDefinition } from '@deepseek-ai/dsh-tools'
13
14
  import type { Context } from '@deepseek-ai/cordis'
14
15
  import { VISION_SKILLS_CONTENT, VISION_SKILLS_NAME } from './skill.ts'
@@ -78,10 +79,50 @@ function isBundledSkillResult(value: unknown): boolean {
78
79
  && isBundledSkillContent(value.content)
79
80
  }
80
81
 
82
+ /** Event names for one settled programmatic (PTC) Tool dispatch. */
83
+ const PTC_DISPATCH_EVENT_TYPES: readonly string[] = ['tool/ptc-dispatch', 'tool/code-dispatch']
84
+
85
+ /**
86
+ * Whether one PTC dispatch event records a successful load of the bundled
87
+ * Skill. The event is `tool/code-dispatch` through 0.1.2 and `tool/ptc-dispatch`
88
+ * from 0.1.5, so the name is compared outside the typed union and the payload is
89
+ * read structurally instead of narrowing on the installed host's literal.
90
+ * @param event - one durable Session event.
91
+ * @returns true when this dispatch ran the bundled Skill and returned its content.
92
+ */
93
+ function isBundledSkillPtcDispatch(event: SessionEvent): boolean {
94
+ const type: string = event.type
95
+ if (!PTC_DISPATCH_EVENT_TYPES.includes(type)) return false
96
+ const data = (event as { data?: unknown }).data
97
+ return isRecord(data)
98
+ && data.name === 'skill'
99
+ && data.isError === false
100
+ && isVisionSkillArguments(data.arguments)
101
+ && containsBundledSkillContent(Array.isArray(data.content) ? data.content : [])
102
+ }
103
+
104
+ /**
105
+ * Read a Session's durable event log across both published accessors: 0.1.1 and
106
+ * earlier expose the `events` getter, while 0.1.2 replaced it with
107
+ * `snapshotEvents()` and dropped the property from the type. Probing
108
+ * structurally keeps every release line this package declares working at
109
+ * runtime and still compiles against the installed 0.1.5 typings.
110
+ * @param session - the Session whose durable history is inspected.
111
+ * @returns the event log in log order, empty when neither accessor is present.
112
+ */
113
+ function sessionEvents(session: Session): readonly SessionEvent[] {
114
+ const adapter = session as unknown as {
115
+ snapshotEvents?: () => readonly SessionEvent[]
116
+ events?: readonly SessionEvent[]
117
+ }
118
+ return typeof adapter.snapshotEvents === 'function' ? adapter.snapshotEvents() : adapter.events ?? []
119
+ }
120
+
81
121
  /** Whether durable history proves that this Session loaded the bundled Skill. */
82
122
  function hasLoadedVisionSkill(session: Session): boolean {
83
123
  const nativeCalls = new Set<string>()
84
- for (const event of session.events) {
124
+ const events = sessionEvents(session)
125
+ for (const event of events) {
85
126
  if (event.type === 'user/message') {
86
127
  const source = event.data.source
87
128
  if (source.kind === 'skill-invocation'
@@ -103,11 +144,7 @@ function hasLoadedVisionSkill(session: Session): boolean {
103
144
  && containsBundledSkillContent(block.content)) return true
104
145
  continue
105
146
  }
106
- if (event.type === 'tool/code-dispatch'
107
- && event.data.name === 'skill'
108
- && event.data.isError === false
109
- && isVisionSkillArguments(event.data.arguments)
110
- && containsBundledSkillContent(event.data.content)) return true
147
+ if (isBundledSkillPtcDispatch(event)) return true
111
148
  }
112
149
  return false
113
150
  }
package/src/tools.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { ContentBlock } from '@deepseek-ai/dsh-llm'
10
- import { defineTool, type JsonValue, type ToolRunContext, type ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
10
+ import { defineTool, type ToolRunContext, type ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
11
11
  import {
12
12
  VisionToolkitRuntime,
13
13
  type CropRequest,
@@ -23,6 +23,17 @@ import {
23
23
  } from './runtime.ts'
24
24
  import { platformTempDirectory } from './paths.ts'
25
25
 
26
+ /**
27
+ * A value that round-trips through JSON without loss, as the host transports it
28
+ * through `presentationMeta`. DSH 0.1.2 stopped exporting this type from
29
+ * `@deepseek-ai/dsh-tools`, and 0.1.5 keeps it in `@deepseek-ai/dsh-util-values`,
30
+ * a package that does not exist before 0.1.2-alpha.2, so no single specifier
31
+ * covers every release line this package declares. Both homes declare the
32
+ * identical structural union, so redeclaring it here costs nothing and lets old
33
+ * and new hosts compile against the same contract.
34
+ */
35
+ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
36
+
26
37
  const renderJson = (_args: unknown, value: unknown): ContentBlock[] => [{
27
38
  type: 'text',
28
39
  text: JSON.stringify(value, null, 2),