dsh-ntr 0.3.2 → 0.3.4
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.
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/lib/bridge.js +1930 -60
- package/lib/client/activity-pill.js +156 -0
- package/lib/client/branch-action.js +16 -0
- package/lib/client/index.js +44 -4
- package/lib/client/model-seat.js +8 -2
- package/lib/client/store.js +32 -0
- package/lib/client.js +228 -5
- package/lib/client.js.map +1 -1
- package/lib/impl.js +1281 -23
- package/lib/index.js +27 -0
- package/lib/tps.js +32 -0
- package/lib/types/bridge.d.ts +636 -12
- package/lib/types/client/activity-pill.d.ts +81 -0
- package/lib/types/client/branch-action.d.ts +8 -1
- package/lib/types/client/model-seat.d.ts +21 -0
- package/lib/types/client/store.d.ts +10 -0
- package/lib/types/impl.d.ts +61 -6
- package/lib/types/protocol.d.ts +22 -0
- package/lib/types/tps.d.ts +17 -0
- package/package.json +1 -1
- package/src/bridge.ts +2216 -60
- package/src/client/activity-pill.tsx +201 -0
- package/src/client/branch-action.tsx +19 -1
- package/src/client/index.tsx +36 -4
- package/src/client/model-seat.tsx +28 -3
- package/src/client/store.ts +37 -0
- package/src/impl.ts +1268 -27
- package/src/index.ts +25 -0
- package/src/protocol.ts +24 -0
- package/src/tps.ts +40 -0
package/src/impl.ts
CHANGED
|
@@ -867,6 +867,48 @@ export function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string, string
|
|
|
867
867
|
|
|
868
868
|
// ---- Model catalog (pure parsing of ACP `configOptions`) ----
|
|
869
869
|
|
|
870
|
+
/**
|
|
871
|
+
* ACP categories are free-form UX hints, not identity. Qoder CN tags
|
|
872
|
+
* `reasoning_effort` as `category: 'model'` (the spec means `thought_level`),
|
|
873
|
+
* and nothing stops an agent from reusing a category for several options.
|
|
874
|
+
* Slots are therefore assigned by `configId` first and category second, and a
|
|
875
|
+
* same-category option NEVER replaces another option's slot: whatever loses
|
|
876
|
+
* stays reachable through `extras` instead of silently becoming the model list.
|
|
877
|
+
*/
|
|
878
|
+
const EFFORT_OPTION_HINT = /effort|thinking|thought|reason/i
|
|
879
|
+
|
|
880
|
+
function isModelSelector(configId: string, name: string): boolean {
|
|
881
|
+
return configId === 'model' || name.trim().toLowerCase() === 'model'
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
type CatalogSlot = 'model' | 'thought' | 'mode'
|
|
885
|
+
|
|
886
|
+
interface CatalogSlotCandidate {
|
|
887
|
+
slot: CatalogSlot
|
|
888
|
+
rank: number
|
|
889
|
+
option: AcpCatalogOption
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function catalogSlotFor(category: string | undefined, configId: string, name: string): Omit<CatalogSlotCandidate, 'option'> | undefined {
|
|
893
|
+
const effortish = EFFORT_OPTION_HINT.test(configId) || EFFORT_OPTION_HINT.test(name)
|
|
894
|
+
if (category === 'model') {
|
|
895
|
+
// The option explicitly named `model` is the selector even when other
|
|
896
|
+
// model-scoped options are present.
|
|
897
|
+
if (isModelSelector(configId, name)) return { slot: 'model', rank: 3 }
|
|
898
|
+
// A model-scoped effort is mislabelled, not a model: route it to the
|
|
899
|
+
// thought chip when no real `thought_level` option is published.
|
|
900
|
+
if (effortish) return { slot: 'thought', rank: 1 }
|
|
901
|
+
// An unknown model-scoped select: a last-resort selector only.
|
|
902
|
+
return { slot: 'model', rank: 1 }
|
|
903
|
+
}
|
|
904
|
+
if (category === 'thought_level') return { slot: 'thought', rank: 3 }
|
|
905
|
+
if (category === 'mode') return { slot: 'mode', rank: 2 }
|
|
906
|
+
// `model_config` is where the spec puts model-dependent knobs (effort,
|
|
907
|
+
// context size); only the effort-shaped ones have a dedicated control.
|
|
908
|
+
if (category === 'model_config' && effortish) return { slot: 'thought', rank: 1 }
|
|
909
|
+
return undefined
|
|
910
|
+
}
|
|
911
|
+
|
|
870
912
|
/**
|
|
871
913
|
* Extract select options from a raw ACP `configOptions` payload.
|
|
872
914
|
*
|
|
@@ -880,24 +922,19 @@ export function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string, string
|
|
|
880
922
|
export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
|
|
881
923
|
const out: AcpAgentCatalog = {}
|
|
882
924
|
if (!Array.isArray(configOptions)) return out
|
|
883
|
-
const
|
|
925
|
+
const definitions: AcpCatalogOption[] = []
|
|
926
|
+
const candidates: CatalogSlotCandidate[] = []
|
|
884
927
|
for (const raw of configOptions) {
|
|
885
928
|
if (typeof raw !== 'object' || raw === null) continue
|
|
886
929
|
const row = raw as Record<string, unknown>
|
|
887
930
|
const category = typeof row.category === 'string' ? row.category : undefined
|
|
888
|
-
const slot = category === 'model'
|
|
889
|
-
? 'model'
|
|
890
|
-
: category === 'thought_level'
|
|
891
|
-
? 'thought'
|
|
892
|
-
: category === 'mode'
|
|
893
|
-
? 'mode'
|
|
894
|
-
: undefined
|
|
895
931
|
const configIdRaw = row.configId ?? row.id
|
|
896
932
|
if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number') continue
|
|
897
933
|
const configId = String(configIdRaw)
|
|
898
934
|
if (configId === '') continue
|
|
899
935
|
const choices: NonNullable<AcpAgentCatalog['model']>['choices'] = []
|
|
900
936
|
collectCatalogChoices(row.options, choices)
|
|
937
|
+
const name = typeof row.name === 'string' && row.name !== '' ? row.name : configId
|
|
901
938
|
// A boolean toggle has no `options` at all; its current value is a JSON
|
|
902
939
|
// boolean. It is encoded as 'true' / 'false' so the catalog stays
|
|
903
940
|
// string-typed for every existing reader, and carries `kind` so the
|
|
@@ -906,7 +943,7 @@ export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
|
|
|
906
943
|
|| (typeof row.currentValue === 'boolean' && choices.length === 0)
|
|
907
944
|
const option: AcpCatalogOption = {
|
|
908
945
|
configId,
|
|
909
|
-
name
|
|
946
|
+
name,
|
|
910
947
|
...(booleanKind ? { kind: 'boolean' as const } : {}),
|
|
911
948
|
currentValue: booleanKind
|
|
912
949
|
? row.currentValue === true || row.currentValue === 'true'
|
|
@@ -915,21 +952,69 @@ export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
|
|
|
915
952
|
: typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
|
|
916
953
|
choices,
|
|
917
954
|
}
|
|
955
|
+
definitions.push(option)
|
|
918
956
|
// A toggle is never a model / thought / mode SELECT: those controls pick
|
|
919
957
|
// among choices. It belongs to the advanced base, where it is a checkbox.
|
|
920
|
-
if (
|
|
921
|
-
|
|
922
|
-
|
|
958
|
+
if (booleanKind) continue
|
|
959
|
+
const slot = catalogSlotFor(category, configId, name)
|
|
960
|
+
if (slot !== undefined) candidates.push({ ...slot, option })
|
|
961
|
+
}
|
|
962
|
+
const winnerIds = new Set<string>()
|
|
963
|
+
for (const slot of ['model', 'thought', 'mode'] as const) {
|
|
964
|
+
let winner: CatalogSlotCandidate | undefined
|
|
965
|
+
for (const candidate of candidates) {
|
|
966
|
+
if (candidate.slot !== slot) continue
|
|
967
|
+
if (winner === undefined || candidate.rank > winner.rank) {
|
|
968
|
+
winner = candidate
|
|
969
|
+
} else if (candidate.rank === winner.rank && candidate.option.configId === winner.option.configId) {
|
|
970
|
+
// The same option redefined later in one answer: the last definition
|
|
971
|
+
// is the current one.
|
|
972
|
+
winner = candidate
|
|
973
|
+
}
|
|
923
974
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
975
|
+
if (winner === undefined) continue
|
|
976
|
+
out[slot] = winner.option
|
|
977
|
+
winnerIds.add(winner.option.configId)
|
|
978
|
+
}
|
|
979
|
+
// Choice-less options have nothing to pick; they would render an empty
|
|
980
|
+
// section in the advanced menu, so they stay out of the catalog entirely —
|
|
981
|
+
// except a boolean, whose whole point is that it has no choices. Anything
|
|
982
|
+
// that lost a slot race is still a real, settable option: keep it here.
|
|
983
|
+
const extras = new Map<string, AcpCatalogOption>()
|
|
984
|
+
for (const option of definitions) {
|
|
985
|
+
if (winnerIds.has(option.configId)) continue
|
|
986
|
+
if (option.choices.length > 0 || option.kind === 'boolean') extras.set(option.configId, option)
|
|
928
987
|
}
|
|
929
988
|
if (extras.size > 0) out.extras = [...extras.values()]
|
|
930
989
|
return out
|
|
931
990
|
}
|
|
932
991
|
|
|
992
|
+
/**
|
|
993
|
+
* Whether a payload names ONE model selector without relying on category
|
|
994
|
+
* alone. When an agent publishes several `category: 'model'` options and none
|
|
995
|
+
* is explicitly the `model` id, the category is not trustworthy enough to
|
|
996
|
+
* reject the user's pick — callers must fail open instead of reporting
|
|
997
|
+
* `model_unavailable`.
|
|
998
|
+
*/
|
|
999
|
+
export function hasUnambiguousModelSelector(configOptions: unknown): boolean {
|
|
1000
|
+
if (!Array.isArray(configOptions)) return true
|
|
1001
|
+
let scoped = 0
|
|
1002
|
+
let explicit = false
|
|
1003
|
+
for (const raw of configOptions) {
|
|
1004
|
+
if (typeof raw !== 'object' || raw === null) continue
|
|
1005
|
+
const row = raw as Record<string, unknown>
|
|
1006
|
+
if (row.category !== 'model') continue
|
|
1007
|
+
const configIdRaw = row.configId ?? row.id
|
|
1008
|
+
if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number') continue
|
|
1009
|
+
const configId = String(configIdRaw)
|
|
1010
|
+
if (configId === '') continue
|
|
1011
|
+
scoped += 1
|
|
1012
|
+
const name = typeof row.name === 'string' ? row.name : ''
|
|
1013
|
+
if (isModelSelector(configId, name)) explicit = true
|
|
1014
|
+
}
|
|
1015
|
+
return explicit || scoped <= 1
|
|
1016
|
+
}
|
|
1017
|
+
|
|
933
1018
|
/**
|
|
934
1019
|
* Fold one per-session option answer's SHAPE into an agent-level catalog.
|
|
935
1020
|
*
|
|
@@ -1387,8 +1472,1053 @@ export function describeUnhandledUpdate(kind: string, update: Record<string, unk
|
|
|
1387
1472
|
* injected context too (goal/subagent/skill material), and that turn carries no
|
|
1388
1473
|
* user intent to send anywhere.
|
|
1389
1474
|
*/
|
|
1475
|
+
export interface PromptImageContent {
|
|
1476
|
+
type: 'image'
|
|
1477
|
+
data: string
|
|
1478
|
+
mimeType: string
|
|
1479
|
+
uri?: string
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
export interface PromptTextContent {
|
|
1483
|
+
type: 'text'
|
|
1484
|
+
text: string
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
export type PromptContentBlock = PromptTextContent | PromptImageContent
|
|
1488
|
+
|
|
1489
|
+
/** Helper: guess MIME type from file extension or URL */
|
|
1490
|
+
export function guessImageMimeType(pathOrUrl?: string): string | undefined {
|
|
1491
|
+
if (!pathOrUrl) return undefined
|
|
1492
|
+
const clean = pathOrUrl.split('?')[0].split('#')[0].toLowerCase()
|
|
1493
|
+
if (clean.endsWith('.jpg') || clean.endsWith('.jpeg')) return 'image/jpeg'
|
|
1494
|
+
if (clean.endsWith('.png')) return 'image/png'
|
|
1495
|
+
if (clean.endsWith('.gif')) return 'image/gif'
|
|
1496
|
+
if (clean.endsWith('.webp')) return 'image/webp'
|
|
1497
|
+
if (clean.endsWith('.svg') || clean.endsWith('.svgz')) return 'image/svg+xml'
|
|
1498
|
+
if (clean.endsWith('.bmp')) return 'image/bmp'
|
|
1499
|
+
if (clean.endsWith('.ico')) return 'image/x-icon'
|
|
1500
|
+
if (clean.endsWith('.tiff') || clean.endsWith('.tif')) return 'image/tiff'
|
|
1501
|
+
if (clean.endsWith('.avif')) return 'image/avif'
|
|
1502
|
+
if (clean.endsWith('.heic')) return 'image/heic'
|
|
1503
|
+
if (clean.endsWith('.heif')) return 'image/heif'
|
|
1504
|
+
if (clean.endsWith('.apng')) return 'image/apng'
|
|
1505
|
+
return undefined
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/** Helper: parse Data URI */
|
|
1509
|
+
export function parseImageDataUrl(url: string): { data: string; mimeType: string } | undefined {
|
|
1510
|
+
if (!url.startsWith('data:')) return undefined
|
|
1511
|
+
const commaIdx = url.indexOf(',')
|
|
1512
|
+
if (commaIdx === -1) return undefined
|
|
1513
|
+
const header = url.slice(5, commaIdx)
|
|
1514
|
+
const raw = url.slice(commaIdx + 1).trim()
|
|
1515
|
+
const parts = header.split(';')
|
|
1516
|
+
const mimeType = parts[0] && parts[0].trim() !== '' ? parts[0].trim().toLowerCase() : 'image/png'
|
|
1517
|
+
if (!mimeType.startsWith('image/')) return undefined
|
|
1518
|
+
const isBase64 = parts.some(p => p.trim() === 'base64')
|
|
1519
|
+
let data: string
|
|
1520
|
+
if (isBase64) {
|
|
1521
|
+
data = raw.replace(/\s+/g, '')
|
|
1522
|
+
} else {
|
|
1523
|
+
try {
|
|
1524
|
+
const decoded = decodeURIComponent(raw)
|
|
1525
|
+
data = typeof Buffer !== 'undefined' ? Buffer.from(decoded).toString('base64') : btoa(decoded)
|
|
1526
|
+
} catch {
|
|
1527
|
+
data = typeof Buffer !== 'undefined' ? Buffer.from(raw).toString('base64') : btoa(raw)
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
return { mimeType, data }
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
export function isLocalFilePath(str: string): boolean {
|
|
1534
|
+
if (typeof str !== 'string' || str === '') return false
|
|
1535
|
+
return isAbsolutePath(str) || str.startsWith('./') || str.startsWith('.\\') || str.startsWith('../') || str.startsWith('..\\')
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
export function normalizeFileUrlToPath(uri: string): string {
|
|
1539
|
+
if (!uri.startsWith('file://')) return uri
|
|
1540
|
+
let raw = uri.slice('file://'.length)
|
|
1541
|
+
try {
|
|
1542
|
+
raw = decodeURIComponent(raw)
|
|
1543
|
+
} catch {}
|
|
1544
|
+
if (raw.startsWith('localhost/')) {
|
|
1545
|
+
raw = raw.slice('localhost'.length)
|
|
1546
|
+
}
|
|
1547
|
+
if (/^\/?[a-zA-Z]:/.test(raw)) {
|
|
1548
|
+
return raw.startsWith('/') ? raw.slice(1) : raw
|
|
1549
|
+
}
|
|
1550
|
+
if (raw.startsWith('/')) {
|
|
1551
|
+
return raw
|
|
1552
|
+
}
|
|
1553
|
+
return `//${raw}`
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function expandHomePath(p: string): string {
|
|
1557
|
+
if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
|
|
1558
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
1559
|
+
const env = process.env
|
|
1560
|
+
const home = env.HOME || env.USERPROFILE || (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined)
|
|
1561
|
+
if (home) {
|
|
1562
|
+
const cleanHome = home.replaceAll('\\', '/').replace(/\/+$/, '')
|
|
1563
|
+
return p === '~' ? cleanHome : `${cleanHome}/${p.slice(2).replaceAll('\\', '/')}`
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return p
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function readImageFileFromContext(filePath: string, context?: unknown): Buffer | Uint8Array | string | undefined {
|
|
1571
|
+
const target = normalizeFileUrlToPath(filePath)
|
|
1572
|
+
const expanded = expandHomePath(target)
|
|
1573
|
+
const pathsToTry = target === expanded ? [target] : [target, expanded]
|
|
1574
|
+
|
|
1575
|
+
const builtinFs = typeof process !== 'undefined' && typeof (process as unknown as { getBuiltinModule?: (m: string) => unknown }).getBuiltinModule === 'function'
|
|
1576
|
+
? ((process as unknown as { getBuiltinModule: (m: string) => unknown }).getBuiltinModule('fs') as { existsSync?: (p: string) => boolean; readFileSync?: (p: string) => Buffer } | null)
|
|
1577
|
+
: null
|
|
1578
|
+
|
|
1579
|
+
for (const p of pathsToTry) {
|
|
1580
|
+
if (context && typeof (context as { readFile?: unknown }).readFile === 'function') {
|
|
1581
|
+
try {
|
|
1582
|
+
const res = (context as { readFile: (path: string) => unknown }).readFile(p)
|
|
1583
|
+
if (res !== undefined) return res as Buffer | Uint8Array | string
|
|
1584
|
+
} catch {}
|
|
1585
|
+
}
|
|
1586
|
+
if (context && typeof (context as { fs?: { readFileSync?: unknown } }).fs?.readFileSync === 'function') {
|
|
1587
|
+
try {
|
|
1588
|
+
const res = (context as { fs: { readFileSync: (path: string) => unknown } }).fs.readFileSync(p)
|
|
1589
|
+
if (res !== undefined) return res as Buffer | Uint8Array | string
|
|
1590
|
+
} catch {}
|
|
1591
|
+
}
|
|
1592
|
+
if (builtinFs && typeof builtinFs.existsSync === 'function' && typeof builtinFs.readFileSync === 'function') {
|
|
1593
|
+
try {
|
|
1594
|
+
if (builtinFs.existsSync(p)) return builtinFs.readFileSync(p)
|
|
1595
|
+
} catch {}
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return undefined
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
/**
|
|
1602
|
+
* Detect image MIME type by examining binary magic numbers.
|
|
1603
|
+
* Supports Buffer, Uint8Array, and base64 strings.
|
|
1604
|
+
*/
|
|
1605
|
+
export function detectImageMimeFromMagic(raw: Buffer | Uint8Array | string | undefined): string | undefined {
|
|
1606
|
+
if (!raw) return undefined
|
|
1607
|
+
let bytes: Uint8Array | undefined
|
|
1608
|
+
if (typeof raw === 'string') {
|
|
1609
|
+
const trimmed = raw.trim()
|
|
1610
|
+
if (trimmed.startsWith('<svg') || (trimmed.startsWith('<?xml') && trimmed.includes('<svg'))) {
|
|
1611
|
+
return 'image/svg+xml'
|
|
1612
|
+
}
|
|
1613
|
+
try {
|
|
1614
|
+
const prefix = trimmed.slice(0, 64).replace(/\s+/g, '')
|
|
1615
|
+
if (typeof Buffer !== 'undefined') {
|
|
1616
|
+
bytes = Buffer.from(prefix, 'base64')
|
|
1617
|
+
} else if (typeof atob !== 'undefined') {
|
|
1618
|
+
const bin = atob(prefix)
|
|
1619
|
+
bytes = new Uint8Array(bin.length)
|
|
1620
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
1621
|
+
}
|
|
1622
|
+
} catch {
|
|
1623
|
+
return undefined
|
|
1624
|
+
}
|
|
1625
|
+
} else if (raw instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(raw))) {
|
|
1626
|
+
bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
if (!bytes || bytes.length < 3) return undefined
|
|
1630
|
+
|
|
1631
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
1632
|
+
if (bytes.length >= 8 &&
|
|
1633
|
+
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
|
|
1634
|
+
bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
|
|
1635
|
+
return 'image/png'
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// JPEG: FF D8 FF
|
|
1639
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
1640
|
+
return 'image/jpeg'
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// GIF: GIF87a or GIF89a (47 49 46 38)
|
|
1644
|
+
if (bytes.length >= 6 &&
|
|
1645
|
+
bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 &&
|
|
1646
|
+
(bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) {
|
|
1647
|
+
return 'image/gif'
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// WebP: RIFF....WEBP (52 49 46 46 .... 57 45 42 50)
|
|
1651
|
+
if (bytes.length >= 12 &&
|
|
1652
|
+
bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
|
1653
|
+
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
|
|
1654
|
+
return 'image/webp'
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// BMP: 42 4D ("BM")
|
|
1658
|
+
if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
|
|
1659
|
+
return 'image/bmp'
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// ICO: 00 00 01 00
|
|
1663
|
+
if (bytes.length >= 4 && bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00) {
|
|
1664
|
+
return 'image/x-icon'
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// TIFF: II*. (49 49 2A 00) or MM.* (4D 4D 00 2A)
|
|
1668
|
+
if (bytes.length >= 4 &&
|
|
1669
|
+
((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
|
|
1670
|
+
(bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a))) {
|
|
1671
|
+
return 'image/tiff'
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// AVIF / HEIC: ftyp box at offset 4 (bytes 4..7 === 'ftyp')
|
|
1675
|
+
if (bytes.length >= 12 &&
|
|
1676
|
+
bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
|
|
1677
|
+
const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]).toLowerCase()
|
|
1678
|
+
if (brand === 'avif' || brand === 'avis') return 'image/avif'
|
|
1679
|
+
if (brand === 'heic' || brand === 'heix' || brand === 'hevc' || brand === 'mif1' || brand === 'msf1') return 'image/heic'
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
return undefined
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function tryResolveFromAttachmentStore(
|
|
1686
|
+
store: Record<string, unknown>,
|
|
1687
|
+
id: string,
|
|
1688
|
+
context?: unknown,
|
|
1689
|
+
attachmentMeta?: unknown,
|
|
1690
|
+
): unknown {
|
|
1691
|
+
const idWithoutPrefix = id.startsWith('sha256:') ? id.slice('sha256:'.length) : undefined
|
|
1692
|
+
const idWithPrefix = id.startsWith('sha256:') ? id : `sha256:${id}`
|
|
1693
|
+
|
|
1694
|
+
const candidates: unknown[] = []
|
|
1695
|
+
if (attachmentMeta && typeof attachmentMeta === 'object') {
|
|
1696
|
+
candidates.push(attachmentMeta)
|
|
1697
|
+
}
|
|
1698
|
+
candidates.push({ attachmentId: idWithPrefix })
|
|
1699
|
+
if (idWithoutPrefix !== undefined) {
|
|
1700
|
+
candidates.push({ attachmentId: idWithoutPrefix })
|
|
1701
|
+
}
|
|
1702
|
+
candidates.push({ attachmentId: id })
|
|
1703
|
+
candidates.push({ id: idWithPrefix })
|
|
1704
|
+
candidates.push({ id })
|
|
1705
|
+
if (idWithoutPrefix !== undefined) {
|
|
1706
|
+
candidates.push({ id: idWithoutPrefix })
|
|
1707
|
+
}
|
|
1708
|
+
candidates.push(id)
|
|
1709
|
+
if (idWithoutPrefix !== undefined) {
|
|
1710
|
+
candidates.push(idWithoutPrefix)
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// Cordis proxies wrap the underlying service instance; if a method call on the proxy fails
|
|
1714
|
+
// or loses this context, fall back to the unwrapped instance (never the class prototype,
|
|
1715
|
+
// which lacks instance state such as `this.root`).
|
|
1716
|
+
const cordisOriginal = Reflect.get(store, Symbol.for('cordis.original')) as Record<string, unknown> | undefined
|
|
1717
|
+
|
|
1718
|
+
// A. LocalAttachmentStore.imageHostPath(ref) -> synchronous local absolute path
|
|
1719
|
+
if (typeof store.imageHostPath === 'function') {
|
|
1720
|
+
const fn = store.imageHostPath as (ref: unknown) => unknown
|
|
1721
|
+
for (const cand of candidates) {
|
|
1722
|
+
if (cand === undefined) continue
|
|
1723
|
+
try {
|
|
1724
|
+
let hostPath: unknown
|
|
1725
|
+
try {
|
|
1726
|
+
hostPath = fn.call(store, cand)
|
|
1727
|
+
} catch {
|
|
1728
|
+
if (cordisOriginal && typeof cordisOriginal.imageHostPath === 'function') {
|
|
1729
|
+
hostPath = (cordisOriginal.imageHostPath as (ref: unknown) => unknown).call(cordisOriginal, cand)
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
if (hostPath && typeof (hostPath as Promise<unknown>).then === 'function') {
|
|
1733
|
+
if (typeof (hostPath as Promise<unknown>).catch === 'function') {
|
|
1734
|
+
(hostPath as Promise<unknown>).catch(() => {})
|
|
1735
|
+
}
|
|
1736
|
+
} else if (typeof hostPath === 'string' && hostPath.trim() !== '') {
|
|
1737
|
+
const fileContent = readImageFileFromContext(hostPath, context)
|
|
1738
|
+
return {
|
|
1739
|
+
path: hostPath,
|
|
1740
|
+
...(fileContent !== undefined ? { data: fileContent } : {}),
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
} catch {}
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// B. LocalAttachmentStore.fileHostPath(ref) -> fallback for file-type attachments
|
|
1748
|
+
if (typeof store.fileHostPath === 'function') {
|
|
1749
|
+
const fn = store.fileHostPath as (ref: unknown) => unknown
|
|
1750
|
+
for (const cand of candidates) {
|
|
1751
|
+
if (cand === undefined) continue
|
|
1752
|
+
try {
|
|
1753
|
+
let hostPath: unknown
|
|
1754
|
+
try {
|
|
1755
|
+
hostPath = fn.call(store, cand)
|
|
1756
|
+
} catch {
|
|
1757
|
+
if (cordisOriginal && typeof cordisOriginal.fileHostPath === 'function') {
|
|
1758
|
+
hostPath = (cordisOriginal.fileHostPath as (ref: unknown) => unknown).call(cordisOriginal, cand)
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
if (hostPath && typeof (hostPath as Promise<unknown>).then === 'function') {
|
|
1762
|
+
if (typeof (hostPath as Promise<unknown>).catch === 'function') {
|
|
1763
|
+
(hostPath as Promise<unknown>).catch(() => {})
|
|
1764
|
+
}
|
|
1765
|
+
} else if (typeof hostPath === 'string' && hostPath.trim() !== '') {
|
|
1766
|
+
const fileContent = readImageFileFromContext(hostPath, context)
|
|
1767
|
+
return {
|
|
1768
|
+
path: hostPath,
|
|
1769
|
+
...(fileContent !== undefined ? { data: fileContent } : {}),
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
} catch {}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// C. LocalAttachmentStore.readImage(ref) -> { ref, data: Uint8Array }
|
|
1777
|
+
if (typeof store.readImage === 'function') {
|
|
1778
|
+
const fn = store.readImage as (ref: unknown) => unknown
|
|
1779
|
+
for (const cand of candidates) {
|
|
1780
|
+
if (cand === undefined) continue
|
|
1781
|
+
try {
|
|
1782
|
+
let res: unknown
|
|
1783
|
+
try {
|
|
1784
|
+
res = fn.call(store, cand)
|
|
1785
|
+
} catch {
|
|
1786
|
+
if (cordisOriginal && typeof cordisOriginal.readImage === 'function') {
|
|
1787
|
+
res = (cordisOriginal.readImage as (ref: unknown) => unknown).call(cordisOriginal, cand)
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
if (res && typeof res === 'object') {
|
|
1791
|
+
// If it's a Promise (async), safely attach catch to avoid unhandledRejection crashes
|
|
1792
|
+
if (typeof (res as Promise<unknown>).then === 'function') {
|
|
1793
|
+
if (typeof (res as Promise<unknown>).catch === 'function') {
|
|
1794
|
+
(res as Promise<unknown>).catch(() => {})
|
|
1795
|
+
}
|
|
1796
|
+
} else {
|
|
1797
|
+
if (res instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(res))) {
|
|
1798
|
+
return res
|
|
1799
|
+
}
|
|
1800
|
+
const data = (res as { data?: unknown }).data
|
|
1801
|
+
if (data instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) || typeof data === 'string') {
|
|
1802
|
+
return { data }
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
} catch {}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// D. readAttachment
|
|
1811
|
+
if (typeof store.readAttachment === 'function') {
|
|
1812
|
+
try {
|
|
1813
|
+
let res: unknown
|
|
1814
|
+
try {
|
|
1815
|
+
res = (store.readAttachment as (id: string) => unknown).call(store, id)
|
|
1816
|
+
} catch {
|
|
1817
|
+
if (cordisOriginal && typeof cordisOriginal.readAttachment === 'function') {
|
|
1818
|
+
res = (cordisOriginal.readAttachment as (id: string) => unknown).call(cordisOriginal, id)
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
if (res && typeof (res as Promise<unknown>).then === 'function') {
|
|
1822
|
+
if (typeof (res as Promise<unknown>).catch === 'function') {
|
|
1823
|
+
(res as Promise<unknown>).catch(() => {})
|
|
1824
|
+
}
|
|
1825
|
+
} else if (res !== undefined) {
|
|
1826
|
+
return res
|
|
1827
|
+
}
|
|
1828
|
+
} catch {}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// E. getAttachment
|
|
1832
|
+
if (typeof store.getAttachment === 'function') {
|
|
1833
|
+
try {
|
|
1834
|
+
let res: unknown
|
|
1835
|
+
try {
|
|
1836
|
+
res = (store.getAttachment as (id: string) => unknown).call(store, id)
|
|
1837
|
+
} catch {
|
|
1838
|
+
if (cordisOriginal && typeof cordisOriginal.getAttachment === 'function') {
|
|
1839
|
+
res = (cordisOriginal.getAttachment as (id: string) => unknown).call(cordisOriginal, id)
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
if (res && typeof (res as Promise<unknown>).then === 'function') {
|
|
1843
|
+
if (typeof (res as Promise<unknown>).catch === 'function') {
|
|
1844
|
+
(res as Promise<unknown>).catch(() => {})
|
|
1845
|
+
}
|
|
1846
|
+
} else if (res !== undefined) {
|
|
1847
|
+
return res
|
|
1848
|
+
}
|
|
1849
|
+
} catch {}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
return undefined
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function tryStoreFreeDiskAttachment(id: string, context?: unknown): unknown {
|
|
1856
|
+
if (typeof id !== 'string') return undefined
|
|
1857
|
+
let hash: string | undefined
|
|
1858
|
+
const trimmed = id.trim()
|
|
1859
|
+
if (trimmed.startsWith('sha256:')) {
|
|
1860
|
+
hash = trimmed.slice('sha256:'.length).toLowerCase()
|
|
1861
|
+
} else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
|
1862
|
+
hash = trimmed.toLowerCase()
|
|
1863
|
+
}
|
|
1864
|
+
if (!hash || hash.length < 2) return undefined
|
|
1865
|
+
|
|
1866
|
+
const prefix = hash.slice(0, 2)
|
|
1867
|
+
const relPaths = [
|
|
1868
|
+
`attachments/v1/objects/${prefix}/${hash}`,
|
|
1869
|
+
`attachments/v1/file-objects/${prefix}/${hash}`,
|
|
1870
|
+
]
|
|
1871
|
+
|
|
1872
|
+
const candidatePaths: string[] = []
|
|
1873
|
+
|
|
1874
|
+
if (typeof process !== 'undefined' && process.env) {
|
|
1875
|
+
const env = process.env
|
|
1876
|
+
if (env.DSH_NTR_STATE_DIR) {
|
|
1877
|
+
const stateDir = env.DSH_NTR_STATE_DIR.replaceAll('\\', '/').replace(/\/+$/, '')
|
|
1878
|
+
const hostDir = stateDir.endsWith('/dsh-ntr') ? stateDir.slice(0, -'/dsh-ntr'.length) : stateDir
|
|
1879
|
+
for (const rel of relPaths) candidatePaths.push(`${hostDir}/${rel}`)
|
|
1880
|
+
}
|
|
1881
|
+
if (env.DSH_DIR) {
|
|
1882
|
+
const dshDir = env.DSH_DIR.replaceAll('\\', '/').replace(/\/+$/, '')
|
|
1883
|
+
for (const rel of relPaths) candidatePaths.push(`${dshDir}/${rel}`)
|
|
1884
|
+
}
|
|
1885
|
+
const home = env.HOME || env.USERPROFILE || (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined)
|
|
1886
|
+
if (home) {
|
|
1887
|
+
const cleanHome = home.replaceAll('\\', '/').replace(/\/+$/, '')
|
|
1888
|
+
for (const rel of relPaths) {
|
|
1889
|
+
candidatePaths.push(`${cleanHome}/.dsh/${rel}`)
|
|
1890
|
+
candidatePaths.push(`${cleanHome}\\.dsh\\${rel.replaceAll('/', '\\')}`)
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
for (const rel of relPaths) candidatePaths.push(`~/.dsh/${rel}`)
|
|
1895
|
+
|
|
1896
|
+
for (const p of candidatePaths) {
|
|
1897
|
+
const fileContent = readImageFileFromContext(p, context)
|
|
1898
|
+
if (fileContent !== undefined) {
|
|
1899
|
+
return {
|
|
1900
|
+
path: p,
|
|
1901
|
+
data: fileContent,
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
return undefined
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
export function resolveImageAttachmentFromContext(
|
|
1910
|
+
id: string,
|
|
1911
|
+
context?: unknown,
|
|
1912
|
+
attachmentMeta?: unknown,
|
|
1913
|
+
rootContext?: unknown,
|
|
1914
|
+
): unknown {
|
|
1915
|
+
const root = rootContext ?? context
|
|
1916
|
+
if (context && typeof context === 'object') {
|
|
1917
|
+
const ctx = context as Record<string, unknown>
|
|
1918
|
+
|
|
1919
|
+
const direct = tryResolveFromAttachmentStore(ctx, id, root, attachmentMeta)
|
|
1920
|
+
if (direct !== undefined) return direct
|
|
1921
|
+
|
|
1922
|
+
if (typeof ctx.get === 'function') {
|
|
1923
|
+
try {
|
|
1924
|
+
const svc = (ctx.get as (name: string) => unknown)('attachments') ?? (ctx.get as (name: string) => unknown)('attachmentService')
|
|
1925
|
+
if (svc && typeof svc === 'object') {
|
|
1926
|
+
const res = resolveImageAttachmentFromContext(id, svc, attachmentMeta, root)
|
|
1927
|
+
if (res !== undefined) return res
|
|
1928
|
+
}
|
|
1929
|
+
} catch {}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
try {
|
|
1933
|
+
if (ctx.attachmentService && typeof ctx.attachmentService === 'object') {
|
|
1934
|
+
const res = resolveImageAttachmentFromContext(id, ctx.attachmentService, attachmentMeta, root)
|
|
1935
|
+
if (res !== undefined) return res
|
|
1936
|
+
}
|
|
1937
|
+
} catch {}
|
|
1938
|
+
|
|
1939
|
+
try {
|
|
1940
|
+
const attStore = ctx.attachments
|
|
1941
|
+
if (attStore) {
|
|
1942
|
+
if (typeof attStore === 'object') {
|
|
1943
|
+
const fromStore = tryResolveFromAttachmentStore(attStore as Record<string, unknown>, id, root, attachmentMeta)
|
|
1944
|
+
if (fromStore !== undefined) return fromStore
|
|
1945
|
+
}
|
|
1946
|
+
if (attStore instanceof Map || typeof (attStore as { get?: unknown }).get === 'function') {
|
|
1947
|
+
try { return (attStore as Map<string, unknown>).get(id) } catch {}
|
|
1948
|
+
}
|
|
1949
|
+
if (typeof attStore === 'object' && Object.prototype.hasOwnProperty.call(attStore, id)) {
|
|
1950
|
+
return (attStore as Record<string, unknown>)[id]
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
} catch {}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
return tryStoreFreeDiskAttachment(id, root)
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function toBase64(data: Buffer | Uint8Array | string): string {
|
|
1960
|
+
if (typeof data === 'string') return data.replace(/\s+/g, '')
|
|
1961
|
+
if (typeof Buffer !== 'undefined') return Buffer.from(data).toString('base64')
|
|
1962
|
+
let binary = ''
|
|
1963
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
|
|
1964
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
1965
|
+
binary += String.fromCharCode(bytes[i])
|
|
1966
|
+
}
|
|
1967
|
+
return btoa(binary)
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
/**
|
|
1971
|
+
* Extract an image payload (Base64 data + MIME type) from an image block and optional context.
|
|
1972
|
+
*/
|
|
1973
|
+
export function resolveImageContent(block: unknown, context?: unknown): PromptImageContent | undefined {
|
|
1974
|
+
if (!block || typeof block !== 'object') return undefined
|
|
1975
|
+
const b = block as Record<string, unknown>
|
|
1976
|
+
const attRecord = b.attachment && typeof b.attachment === 'object' ? (b.attachment as Record<string, unknown>) : undefined
|
|
1977
|
+
const isExplicitImageBlock = b.type === 'image' || b.type === 'image_url' || b.type === 'attachment'
|
|
1978
|
+
const explicitMime = (typeof attRecord?.mediaType === 'string' && attRecord.mediaType.trim().toLowerCase()) ||
|
|
1979
|
+
(typeof attRecord?.mimeType === 'string' && attRecord.mimeType.trim().toLowerCase()) ||
|
|
1980
|
+
(typeof b.mediaType === 'string' && b.mediaType.trim().toLowerCase()) ||
|
|
1981
|
+
(typeof b.mimeType === 'string' && b.mimeType.trim().toLowerCase()) ||
|
|
1982
|
+
(typeof b.contentType === 'string' && b.contentType.trim().toLowerCase()) ||
|
|
1983
|
+
undefined
|
|
1984
|
+
|
|
1985
|
+
// 1. Direct base64 data: { data: string | Buffer | Uint8Array, mimeType?: string }
|
|
1986
|
+
if (typeof b.data === 'string' && b.data.trim() !== '') {
|
|
1987
|
+
if (b.data.startsWith('data:')) {
|
|
1988
|
+
const parsed = parseImageDataUrl(b.data)
|
|
1989
|
+
if (parsed) {
|
|
1990
|
+
const mimeType = explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png'
|
|
1991
|
+
return { type: 'image', data: parsed.data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
|
|
1992
|
+
}
|
|
1993
|
+
return undefined
|
|
1994
|
+
}
|
|
1995
|
+
const mimeType = explicitMime || detectImageMimeFromMagic(b.data) || (isExplicitImageBlock ? 'image/png' : undefined)
|
|
1996
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
1997
|
+
return {
|
|
1998
|
+
type: 'image',
|
|
1999
|
+
data: b.data.trim().replace(/\s+/g, ''),
|
|
2000
|
+
mimeType,
|
|
2001
|
+
...(typeof b.uri === 'string' ? { uri: b.uri } : {}),
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.data)) {
|
|
2006
|
+
const mimeType = explicitMime || detectImageMimeFromMagic(b.data) || (isExplicitImageBlock ? 'image/png' : undefined)
|
|
2007
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2008
|
+
return { type: 'image', data: b.data.toString('base64'), mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
if (b.data instanceof Uint8Array) {
|
|
2012
|
+
const mimeType = explicitMime || detectImageMimeFromMagic(b.data) || (isExplicitImageBlock ? 'image/png' : undefined)
|
|
2013
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2014
|
+
return { type: 'image', data: toBase64(b.data), mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
// 2. Anthropic style: source: { type: 'base64', media_type: string, data: string }
|
|
2019
|
+
if (b.source && typeof b.source === 'object') {
|
|
2020
|
+
const src = b.source as Record<string, unknown>
|
|
2021
|
+
const srcMime = (typeof src.media_type === 'string' && src.media_type.trim().toLowerCase()) ||
|
|
2022
|
+
(typeof src.mimeType === 'string' && src.mimeType.trim().toLowerCase()) ||
|
|
2023
|
+
explicitMime ||
|
|
2024
|
+
(typeof src.data === 'string' || src.data instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(src.data)) ? detectImageMimeFromMagic(src.data as Buffer | Uint8Array | string) : undefined) ||
|
|
2025
|
+
'image/png'
|
|
2026
|
+
if (srcMime.startsWith('image/')) {
|
|
2027
|
+
if (typeof src.data === 'string' && src.data.trim() !== '') {
|
|
2028
|
+
return {
|
|
2029
|
+
type: 'image',
|
|
2030
|
+
data: src.data.trim().replace(/\s+/g, ''),
|
|
2031
|
+
mimeType: srcMime,
|
|
2032
|
+
...(typeof b.uri === 'string' ? { uri: b.uri } : {}),
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(src.data)) {
|
|
2036
|
+
return { type: 'image', data: src.data.toString('base64'), mimeType: srcMime, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
|
|
2037
|
+
}
|
|
2038
|
+
if (src.data instanceof Uint8Array) {
|
|
2039
|
+
return { type: 'image', data: toBase64(src.data), mimeType: srcMime, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// 3. Image property: { image: string | Buffer | Uint8Array }
|
|
2045
|
+
if (typeof b.image === 'string' && b.image.trim() !== '') {
|
|
2046
|
+
if (b.image.startsWith('data:')) {
|
|
2047
|
+
const parsed = parseImageDataUrl(b.image)
|
|
2048
|
+
if (parsed) return { type: 'image', data: parsed.data, mimeType: explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png' }
|
|
2049
|
+
return undefined
|
|
2050
|
+
}
|
|
2051
|
+
const mimeType = explicitMime || detectImageMimeFromMagic(b.image) || (isExplicitImageBlock ? 'image/png' : undefined)
|
|
2052
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2053
|
+
return { type: 'image', data: b.image.trim().replace(/\s+/g, ''), mimeType }
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.image)) {
|
|
2057
|
+
const mimeType = explicitMime || detectImageMimeFromMagic(b.image) || (isExplicitImageBlock ? 'image/png' : undefined)
|
|
2058
|
+
if (mimeType && mimeType.startsWith('image/')) return { type: 'image', data: b.image.toString('base64'), mimeType }
|
|
2059
|
+
}
|
|
2060
|
+
if (b.image instanceof Uint8Array) {
|
|
2061
|
+
const mimeType = explicitMime || detectImageMimeFromMagic(b.image) || (isExplicitImageBlock ? 'image/png' : undefined)
|
|
2062
|
+
if (mimeType && mimeType.startsWith('image/')) return { type: 'image', data: toBase64(b.image), mimeType }
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// 4. OpenAI style image_url: { image_url: string | { url: string } }
|
|
2066
|
+
if (b.image_url) {
|
|
2067
|
+
const urlStr = typeof b.image_url === 'string'
|
|
2068
|
+
? b.image_url
|
|
2069
|
+
: (typeof b.image_url === 'object' && b.image_url !== null ? (b.image_url as Record<string, unknown>).url : undefined)
|
|
2070
|
+
if (typeof urlStr === 'string' && urlStr.trim() !== '') {
|
|
2071
|
+
if (urlStr.startsWith('data:')) {
|
|
2072
|
+
const parsed = parseImageDataUrl(urlStr)
|
|
2073
|
+
if (parsed) return { type: 'image', data: parsed.data, mimeType: explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png', uri: urlStr }
|
|
2074
|
+
return undefined
|
|
2075
|
+
}
|
|
2076
|
+
if (urlStr.startsWith('http://') || urlStr.startsWith('https://')) {
|
|
2077
|
+
const mimeType = explicitMime || guessImageMimeType(urlStr) || 'image/png'
|
|
2078
|
+
return { type: 'image', data: '', mimeType, uri: urlStr }
|
|
2079
|
+
}
|
|
2080
|
+
if (urlStr.startsWith('file://') || isLocalFilePath(urlStr)) {
|
|
2081
|
+
const fileContent = readImageFileFromContext(urlStr, context)
|
|
2082
|
+
if (fileContent !== undefined) {
|
|
2083
|
+
let data: string
|
|
2084
|
+
let resolvedMime: string | undefined
|
|
2085
|
+
if (typeof fileContent === 'string') {
|
|
2086
|
+
if (fileContent.startsWith('data:')) {
|
|
2087
|
+
const parsed = parseImageDataUrl(fileContent)
|
|
2088
|
+
if (parsed) {
|
|
2089
|
+
data = parsed.data
|
|
2090
|
+
resolvedMime = parsed.mimeType
|
|
2091
|
+
} else {
|
|
2092
|
+
return undefined
|
|
2093
|
+
}
|
|
2094
|
+
} else {
|
|
2095
|
+
data = fileContent.trim().replace(/\s+/g, '')
|
|
2096
|
+
}
|
|
2097
|
+
} else {
|
|
2098
|
+
data = toBase64(fileContent)
|
|
2099
|
+
}
|
|
2100
|
+
const mimeType = explicitMime || resolvedMime || guessImageMimeType(urlStr) || detectImageMimeFromMagic(fileContent) || (isExplicitImageBlock ? 'image/png' : undefined)
|
|
2101
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2102
|
+
return { type: 'image', data, mimeType, uri: urlStr }
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// 5. Direct buffer / content on block: { buffer: Buffer } or { content: Buffer }
|
|
2110
|
+
if (b.buffer && (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.buffer) || b.buffer instanceof Uint8Array)) {
|
|
2111
|
+
const data = typeof Buffer !== 'undefined' && Buffer.isBuffer(b.buffer) ? b.buffer.toString('base64') : toBase64(b.buffer as Uint8Array)
|
|
2112
|
+
const mimeType = explicitMime || detectImageMimeFromMagic(b.buffer as Buffer | Uint8Array) || (isExplicitImageBlock ? 'image/png' : undefined)
|
|
2113
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2114
|
+
return { type: 'image', data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
if (b.content && (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.content) || b.content instanceof Uint8Array)) {
|
|
2118
|
+
const data = typeof Buffer !== 'undefined' && Buffer.isBuffer(b.content) ? b.content.toString('base64') : toBase64(b.content as Uint8Array)
|
|
2119
|
+
const mimeType = explicitMime || detectImageMimeFromMagic(b.content as Buffer | Uint8Array) || (isExplicitImageBlock ? 'image/png' : undefined)
|
|
2120
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2121
|
+
return { type: 'image', data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// 6. Data URL or Remote URL in `url`
|
|
2126
|
+
if (typeof b.url === 'string') {
|
|
2127
|
+
if (b.url.startsWith('data:')) {
|
|
2128
|
+
const parsed = parseImageDataUrl(b.url)
|
|
2129
|
+
if (parsed) return { type: 'image', data: parsed.data, mimeType: explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png', uri: b.url }
|
|
2130
|
+
return undefined
|
|
2131
|
+
}
|
|
2132
|
+
if (b.url.startsWith('http://') || b.url.startsWith('https://')) {
|
|
2133
|
+
const mimeType = explicitMime || guessImageMimeType(b.url) || 'image/png'
|
|
2134
|
+
return { type: 'image', data: '', mimeType, uri: b.url }
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// 7. File path in `path`, `filePath`, or `url: file://...`
|
|
2139
|
+
let filePath: string | undefined
|
|
2140
|
+
if (typeof b.path === 'string' && b.path !== '') filePath = b.path
|
|
2141
|
+
else if (typeof b.filePath === 'string' && b.filePath !== '') filePath = b.filePath
|
|
2142
|
+
else if (typeof b.url === 'string' && (b.url.startsWith('file://') || isLocalFilePath(b.url))) filePath = b.url
|
|
2143
|
+
|
|
2144
|
+
if (filePath !== undefined) {
|
|
2145
|
+
const fileContent = readImageFileFromContext(filePath, context)
|
|
2146
|
+
if (fileContent !== undefined) {
|
|
2147
|
+
let data: string
|
|
2148
|
+
let resolvedMime: string | undefined
|
|
2149
|
+
if (typeof fileContent === 'string') {
|
|
2150
|
+
if (fileContent.startsWith('data:')) {
|
|
2151
|
+
const parsed = parseImageDataUrl(fileContent)
|
|
2152
|
+
if (parsed) {
|
|
2153
|
+
data = parsed.data
|
|
2154
|
+
resolvedMime = parsed.mimeType
|
|
2155
|
+
} else {
|
|
2156
|
+
return undefined
|
|
2157
|
+
}
|
|
2158
|
+
} else {
|
|
2159
|
+
data = fileContent.trim().replace(/\s+/g, '')
|
|
2160
|
+
}
|
|
2161
|
+
} else {
|
|
2162
|
+
data = toBase64(fileContent)
|
|
2163
|
+
}
|
|
2164
|
+
const mimeType = explicitMime ||
|
|
2165
|
+
resolvedMime ||
|
|
2166
|
+
guessImageMimeType(filePath) ||
|
|
2167
|
+
detectImageMimeFromMagic(fileContent) ||
|
|
2168
|
+
(isExplicitImageBlock ? 'image/png' : undefined)
|
|
2169
|
+
|
|
2170
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2171
|
+
let uri = typeof b.url === 'string' ? b.url : undefined
|
|
2172
|
+
if (!uri) {
|
|
2173
|
+
if (filePath.startsWith('file://')) {
|
|
2174
|
+
uri = filePath
|
|
2175
|
+
} else if (isAbsolutePath(filePath)) {
|
|
2176
|
+
if (filePath.startsWith('//') || filePath.startsWith('\\\\')) {
|
|
2177
|
+
uri = `file://${filePath.replace(/^[\/\\]+/, '').replace(/\\/g, '/')}`
|
|
2178
|
+
} else if (/^[a-zA-Z]:/.test(filePath)) {
|
|
2179
|
+
uri = `file:///${filePath.replace(/\\/g, '/')}`
|
|
2180
|
+
} else {
|
|
2181
|
+
uri = `file://${filePath.startsWith('/') ? filePath : `/${filePath}`}`
|
|
2182
|
+
}
|
|
2183
|
+
} else {
|
|
2184
|
+
uri = filePath
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
return {
|
|
2188
|
+
type: 'image',
|
|
2189
|
+
data,
|
|
2190
|
+
mimeType,
|
|
2191
|
+
uri,
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// 8. Attachment resolution via context
|
|
2198
|
+
let attachmentId: string | undefined
|
|
2199
|
+
if (attRecord && typeof attRecord.attachmentId === 'string' && attRecord.attachmentId.trim() !== '') {
|
|
2200
|
+
attachmentId = attRecord.attachmentId.trim()
|
|
2201
|
+
} else if (typeof b.attachmentId === 'string' && b.attachmentId.trim() !== '') {
|
|
2202
|
+
attachmentId = b.attachmentId.trim()
|
|
2203
|
+
} else if (attRecord && typeof attRecord.id === 'string' && attRecord.id.trim() !== '') {
|
|
2204
|
+
attachmentId = attRecord.id.trim()
|
|
2205
|
+
} else if (typeof b.id === 'string' && b.id.trim() !== '') {
|
|
2206
|
+
attachmentId = b.id.trim()
|
|
2207
|
+
} else if (typeof b.attachment === 'string' && b.attachment.trim() !== '') {
|
|
2208
|
+
attachmentId = b.attachment.trim()
|
|
2209
|
+
} else if (typeof b.url === 'string' && b.url.startsWith('attachment:')) {
|
|
2210
|
+
attachmentId = b.url.replace(/^attachment:\/\/|^attachment:/, '')
|
|
2211
|
+
} else if (typeof b.image_url === 'string' && b.image_url.startsWith('attachment:')) {
|
|
2212
|
+
attachmentId = b.image_url.replace(/^attachment:\/\/|^attachment:/, '')
|
|
2213
|
+
} else if (b.image_url && typeof b.image_url === 'object' && typeof (b.image_url as Record<string, unknown>).url === 'string' && ((b.image_url as Record<string, unknown>).url as string).startsWith('attachment:')) {
|
|
2214
|
+
attachmentId = ((b.image_url as Record<string, unknown>).url as string).replace(/^attachment:\/\/|^attachment:/, '')
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
if (attachmentId !== undefined) {
|
|
2218
|
+
const rawAtt = resolveImageAttachmentFromContext(attachmentId, context, b.attachment)
|
|
2219
|
+
if (rawAtt !== undefined) {
|
|
2220
|
+
if (typeof rawAtt === 'string') {
|
|
2221
|
+
if (rawAtt.startsWith('data:')) {
|
|
2222
|
+
const parsed = parseImageDataUrl(rawAtt)
|
|
2223
|
+
if (parsed) {
|
|
2224
|
+
const mimeType = explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png'
|
|
2225
|
+
return {
|
|
2226
|
+
type: 'image',
|
|
2227
|
+
data: parsed.data,
|
|
2228
|
+
mimeType,
|
|
2229
|
+
uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
return undefined
|
|
2233
|
+
} else if (rawAtt.startsWith('file://') || isLocalFilePath(rawAtt)) {
|
|
2234
|
+
const fileContent = readImageFileFromContext(rawAtt, context)
|
|
2235
|
+
if (fileContent !== undefined) {
|
|
2236
|
+
let data: string
|
|
2237
|
+
let resolvedMime: string | undefined
|
|
2238
|
+
if (typeof fileContent === 'string') {
|
|
2239
|
+
if (fileContent.startsWith('data:')) {
|
|
2240
|
+
const parsed = parseImageDataUrl(fileContent)
|
|
2241
|
+
if (parsed) {
|
|
2242
|
+
data = parsed.data
|
|
2243
|
+
resolvedMime = parsed.mimeType
|
|
2244
|
+
} else {
|
|
2245
|
+
return undefined
|
|
2246
|
+
}
|
|
2247
|
+
} else {
|
|
2248
|
+
data = fileContent.trim().replace(/\s+/g, '')
|
|
2249
|
+
}
|
|
2250
|
+
} else {
|
|
2251
|
+
data = toBase64(fileContent)
|
|
2252
|
+
}
|
|
2253
|
+
const mimeType = explicitMime ||
|
|
2254
|
+
resolvedMime ||
|
|
2255
|
+
(typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
|
|
2256
|
+
guessImageMimeType(rawAtt) ||
|
|
2257
|
+
detectImageMimeFromMagic(fileContent) ||
|
|
2258
|
+
(isExplicitImageBlock ? 'image/png' : undefined)
|
|
2259
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2260
|
+
return { type: 'image', data, mimeType, uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}` }
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
const mimeType = explicitMime ||
|
|
2265
|
+
(typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
|
|
2266
|
+
guessImageMimeType(attachmentId) ||
|
|
2267
|
+
detectImageMimeFromMagic(rawAtt) ||
|
|
2268
|
+
(isExplicitImageBlock ? 'image/png' : undefined)
|
|
2269
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2270
|
+
return { type: 'image', data: rawAtt.trim().replace(/\s+/g, ''), mimeType, uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}` }
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
if (typeof rawAtt === 'object' && rawAtt !== null) {
|
|
2274
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(rawAtt)) {
|
|
2275
|
+
const mimeType = explicitMime ||
|
|
2276
|
+
(typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
|
|
2277
|
+
guessImageMimeType(attachmentId) ||
|
|
2278
|
+
detectImageMimeFromMagic(rawAtt) ||
|
|
2279
|
+
(isExplicitImageBlock ? 'image/png' : undefined)
|
|
2280
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2281
|
+
return {
|
|
2282
|
+
type: 'image',
|
|
2283
|
+
data: rawAtt.toString('base64'),
|
|
2284
|
+
mimeType,
|
|
2285
|
+
uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
if (rawAtt instanceof Uint8Array) {
|
|
2290
|
+
const mimeType = explicitMime ||
|
|
2291
|
+
(typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
|
|
2292
|
+
guessImageMimeType(attachmentId) ||
|
|
2293
|
+
detectImageMimeFromMagic(rawAtt) ||
|
|
2294
|
+
(isExplicitImageBlock ? 'image/png' : undefined)
|
|
2295
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2296
|
+
return {
|
|
2297
|
+
type: 'image',
|
|
2298
|
+
data: toBase64(rawAtt),
|
|
2299
|
+
mimeType,
|
|
2300
|
+
uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
const attObj = rawAtt as Record<string, unknown>
|
|
2305
|
+
let data: string | undefined
|
|
2306
|
+
let resolvedMime: string | undefined
|
|
2307
|
+
|
|
2308
|
+
const attPath = typeof attObj.path === 'string' && attObj.path !== ''
|
|
2309
|
+
? attObj.path
|
|
2310
|
+
: (typeof attObj.filePath === 'string' && attObj.filePath !== ''
|
|
2311
|
+
? attObj.filePath
|
|
2312
|
+
: (typeof attObj.url === 'string' && (attObj.url.startsWith('file://') || isLocalFilePath(attObj.url)) ? attObj.url : undefined))
|
|
2313
|
+
|
|
2314
|
+
let fileContentForMagic: Buffer | Uint8Array | string | undefined
|
|
2315
|
+
|
|
2316
|
+
if (attPath !== undefined) {
|
|
2317
|
+
const fileContent = readImageFileFromContext(attPath, context)
|
|
2318
|
+
if (fileContent !== undefined) {
|
|
2319
|
+
fileContentForMagic = fileContent
|
|
2320
|
+
if (typeof fileContent === 'string') {
|
|
2321
|
+
if (fileContent.startsWith('data:')) {
|
|
2322
|
+
const parsed = parseImageDataUrl(fileContent)
|
|
2323
|
+
if (parsed) {
|
|
2324
|
+
data = parsed.data
|
|
2325
|
+
resolvedMime = parsed.mimeType
|
|
2326
|
+
}
|
|
2327
|
+
} else {
|
|
2328
|
+
data = fileContent.trim().replace(/\s+/g, '')
|
|
2329
|
+
}
|
|
2330
|
+
} else {
|
|
2331
|
+
data = toBase64(fileContent)
|
|
2332
|
+
}
|
|
2333
|
+
if (!resolvedMime) resolvedMime = guessImageMimeType(attPath)
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
if (data === undefined) {
|
|
2338
|
+
if (typeof attObj.data === 'string' && attObj.data.trim() !== '') {
|
|
2339
|
+
if (attObj.data.startsWith('data:')) {
|
|
2340
|
+
const parsed = parseImageDataUrl(attObj.data)
|
|
2341
|
+
if (parsed) {
|
|
2342
|
+
data = parsed.data
|
|
2343
|
+
resolvedMime = parsed.mimeType
|
|
2344
|
+
}
|
|
2345
|
+
} else {
|
|
2346
|
+
data = attObj.data.trim().replace(/\s+/g, '')
|
|
2347
|
+
fileContentForMagic = attObj.data
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.data)) {
|
|
2351
|
+
data = attObj.data.toString('base64')
|
|
2352
|
+
fileContentForMagic = attObj.data
|
|
2353
|
+
}
|
|
2354
|
+
else if (attObj.data instanceof Uint8Array) {
|
|
2355
|
+
data = toBase64(attObj.data)
|
|
2356
|
+
fileContentForMagic = attObj.data
|
|
2357
|
+
}
|
|
2358
|
+
else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.buffer)) {
|
|
2359
|
+
data = attObj.buffer.toString('base64')
|
|
2360
|
+
fileContentForMagic = attObj.buffer
|
|
2361
|
+
}
|
|
2362
|
+
else if (attObj.buffer instanceof Uint8Array) {
|
|
2363
|
+
data = toBase64(attObj.buffer)
|
|
2364
|
+
fileContentForMagic = attObj.buffer
|
|
2365
|
+
}
|
|
2366
|
+
else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.content)) {
|
|
2367
|
+
data = attObj.content.toString('base64')
|
|
2368
|
+
fileContentForMagic = attObj.content
|
|
2369
|
+
}
|
|
2370
|
+
else if (attObj.content instanceof Uint8Array) {
|
|
2371
|
+
data = toBase64(attObj.content)
|
|
2372
|
+
fileContentForMagic = attObj.content
|
|
2373
|
+
}
|
|
2374
|
+
else if (typeof attObj.content === 'string') {
|
|
2375
|
+
if (attObj.content.startsWith('data:')) {
|
|
2376
|
+
const parsed = parseImageDataUrl(attObj.content)
|
|
2377
|
+
if (parsed) {
|
|
2378
|
+
data = parsed.data
|
|
2379
|
+
resolvedMime = parsed.mimeType
|
|
2380
|
+
}
|
|
2381
|
+
} else {
|
|
2382
|
+
data = attObj.content.trim().replace(/\s+/g, '')
|
|
2383
|
+
fileContentForMagic = attObj.content
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
if (data !== undefined) {
|
|
2389
|
+
const mimeType = explicitMime ||
|
|
2390
|
+
(typeof attObj.mediaType === 'string' && attObj.mediaType.toLowerCase()) ||
|
|
2391
|
+
(typeof attObj.mimeType === 'string' && attObj.mimeType.toLowerCase()) ||
|
|
2392
|
+
resolvedMime ||
|
|
2393
|
+
(typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
|
|
2394
|
+
(typeof attObj.name === 'string' ? guessImageMimeType(attObj.name as string) : undefined) ||
|
|
2395
|
+
guessImageMimeType(attachmentId) ||
|
|
2396
|
+
detectImageMimeFromMagic(fileContentForMagic ?? data) ||
|
|
2397
|
+
(isExplicitImageBlock ? 'image/png' : undefined)
|
|
2398
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2399
|
+
return {
|
|
2400
|
+
type: 'image',
|
|
2401
|
+
data,
|
|
2402
|
+
mimeType,
|
|
2403
|
+
uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// 9. Direct b.attachment object
|
|
2412
|
+
if (b.attachment && typeof b.attachment === 'object') {
|
|
2413
|
+
const attObj = b.attachment as Record<string, unknown>
|
|
2414
|
+
let data: string | undefined
|
|
2415
|
+
let resolvedMime: string | undefined
|
|
2416
|
+
|
|
2417
|
+
const attPath = typeof attObj.path === 'string' && attObj.path !== ''
|
|
2418
|
+
? attObj.path
|
|
2419
|
+
: (typeof attObj.filePath === 'string' && attObj.filePath !== ''
|
|
2420
|
+
? attObj.filePath
|
|
2421
|
+
: (typeof attObj.url === 'string' && (attObj.url.startsWith('file://') || isLocalFilePath(attObj.url)) ? attObj.url : undefined))
|
|
2422
|
+
|
|
2423
|
+
let fileContentForMagic: Buffer | Uint8Array | string | undefined
|
|
2424
|
+
|
|
2425
|
+
if (attPath !== undefined) {
|
|
2426
|
+
const fileContent = readImageFileFromContext(attPath, context)
|
|
2427
|
+
if (fileContent !== undefined) {
|
|
2428
|
+
fileContentForMagic = fileContent
|
|
2429
|
+
if (typeof fileContent === 'string') {
|
|
2430
|
+
if (fileContent.startsWith('data:')) {
|
|
2431
|
+
const parsed = parseImageDataUrl(fileContent)
|
|
2432
|
+
if (parsed) {
|
|
2433
|
+
data = parsed.data
|
|
2434
|
+
resolvedMime = parsed.mimeType
|
|
2435
|
+
}
|
|
2436
|
+
} else {
|
|
2437
|
+
data = fileContent.trim().replace(/\s+/g, '')
|
|
2438
|
+
}
|
|
2439
|
+
} else {
|
|
2440
|
+
data = toBase64(fileContent)
|
|
2441
|
+
}
|
|
2442
|
+
if (!resolvedMime) resolvedMime = guessImageMimeType(attPath)
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
if (data === undefined) {
|
|
2447
|
+
if (typeof attObj.data === 'string' && attObj.data.trim() !== '') {
|
|
2448
|
+
if (attObj.data.startsWith('data:')) {
|
|
2449
|
+
const parsed = parseImageDataUrl(attObj.data)
|
|
2450
|
+
if (parsed) {
|
|
2451
|
+
data = parsed.data
|
|
2452
|
+
resolvedMime = parsed.mimeType
|
|
2453
|
+
}
|
|
2454
|
+
} else {
|
|
2455
|
+
data = attObj.data.trim().replace(/\s+/g, '')
|
|
2456
|
+
fileContentForMagic = attObj.data
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.data)) {
|
|
2460
|
+
data = attObj.data.toString('base64')
|
|
2461
|
+
fileContentForMagic = attObj.data
|
|
2462
|
+
}
|
|
2463
|
+
else if (attObj.data instanceof Uint8Array) {
|
|
2464
|
+
data = toBase64(attObj.data)
|
|
2465
|
+
fileContentForMagic = attObj.data
|
|
2466
|
+
}
|
|
2467
|
+
else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.buffer)) {
|
|
2468
|
+
data = attObj.buffer.toString('base64')
|
|
2469
|
+
fileContentForMagic = attObj.buffer
|
|
2470
|
+
}
|
|
2471
|
+
else if (attObj.buffer instanceof Uint8Array) {
|
|
2472
|
+
data = toBase64(attObj.buffer)
|
|
2473
|
+
fileContentForMagic = attObj.buffer
|
|
2474
|
+
}
|
|
2475
|
+
else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.content)) {
|
|
2476
|
+
data = attObj.content.toString('base64')
|
|
2477
|
+
fileContentForMagic = attObj.content
|
|
2478
|
+
}
|
|
2479
|
+
else if (attObj.content instanceof Uint8Array) {
|
|
2480
|
+
data = toBase64(attObj.content)
|
|
2481
|
+
fileContentForMagic = attObj.content
|
|
2482
|
+
}
|
|
2483
|
+
else if (typeof attObj.content === 'string') {
|
|
2484
|
+
if (attObj.content.startsWith('data:')) {
|
|
2485
|
+
const parsed = parseImageDataUrl(attObj.content)
|
|
2486
|
+
if (parsed) {
|
|
2487
|
+
data = parsed.data
|
|
2488
|
+
resolvedMime = parsed.mimeType
|
|
2489
|
+
}
|
|
2490
|
+
} else {
|
|
2491
|
+
data = attObj.content.trim().replace(/\s+/g, '')
|
|
2492
|
+
fileContentForMagic = attObj.content
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
if (data !== undefined) {
|
|
2498
|
+
const mimeType = explicitMime ||
|
|
2499
|
+
(typeof attObj.mediaType === 'string' && attObj.mediaType.toLowerCase()) ||
|
|
2500
|
+
(typeof attObj.mimeType === 'string' && attObj.mimeType.toLowerCase()) ||
|
|
2501
|
+
resolvedMime ||
|
|
2502
|
+
(typeof attObj.name === 'string' ? guessImageMimeType(attObj.name as string) : undefined) ||
|
|
2503
|
+
(attPath ? guessImageMimeType(attPath) : undefined) ||
|
|
2504
|
+
detectImageMimeFromMagic(fileContentForMagic ?? data) ||
|
|
2505
|
+
(isExplicitImageBlock ? 'image/png' : undefined)
|
|
2506
|
+
if (mimeType && mimeType.startsWith('image/')) {
|
|
2507
|
+
return { type: 'image', data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
return undefined
|
|
2513
|
+
}
|
|
1390
2514
|
export type ForwardPromptSelection =
|
|
1391
|
-
| {
|
|
2515
|
+
| {
|
|
2516
|
+
kind: 'prompt'
|
|
2517
|
+
text: string
|
|
2518
|
+
id?: string
|
|
2519
|
+
images?: PromptImageContent[]
|
|
2520
|
+
content?: PromptContentBlock[]
|
|
2521
|
+
}
|
|
1392
2522
|
| { kind: 'no-user-input' }
|
|
1393
2523
|
| { kind: 'empty' }
|
|
1394
2524
|
|
|
@@ -1405,28 +2535,72 @@ export type ForwardPromptSelection =
|
|
|
1405
2535
|
* (tests, headless callers) and counts as user input.
|
|
1406
2536
|
*
|
|
1407
2537
|
* @param messages - the request's ordered messages, exactly as the host built them.
|
|
1408
|
-
* @
|
|
2538
|
+
* @param context - optional host context or attachment resolver for reading attachments.
|
|
2539
|
+
* @returns the last real user message's text and any image content blocks, or why there is none to forward.
|
|
1409
2540
|
*/
|
|
1410
2541
|
export function selectForwardPrompt(messages: readonly {
|
|
1411
2542
|
role: string
|
|
1412
|
-
content?: Array<{ type?: string; text?: string }>
|
|
2543
|
+
content?: string | Array<{ type?: string; text?: string; [k: string]: unknown }> | { type?: string; text?: string; [k: string]: unknown }
|
|
1413
2544
|
source?: { kind?: string }
|
|
1414
2545
|
id?: string
|
|
1415
|
-
}[]): ForwardPromptSelection {
|
|
2546
|
+
}[], context?: unknown): ForwardPromptSelection {
|
|
1416
2547
|
let sawUserRole = false
|
|
1417
|
-
let last: {
|
|
2548
|
+
let last: {
|
|
2549
|
+
text: string
|
|
2550
|
+
id?: string
|
|
2551
|
+
images?: PromptImageContent[]
|
|
2552
|
+
content?: PromptContentBlock[]
|
|
2553
|
+
} | undefined
|
|
1418
2554
|
for (const message of messages) {
|
|
1419
2555
|
if (message.role !== 'user') continue
|
|
1420
2556
|
sawUserRole = true
|
|
1421
2557
|
const kind = message.source?.kind
|
|
1422
2558
|
if (kind !== undefined && kind !== 'user') continue
|
|
1423
|
-
|
|
2559
|
+
|
|
2560
|
+
const content = message.content ?? (message as { parts?: unknown }).parts
|
|
2561
|
+
const rawBlocks: Array<Record<string, unknown>> = Array.isArray(content)
|
|
2562
|
+
? (content as Array<Record<string, unknown>>)
|
|
2563
|
+
: (typeof content === 'string'
|
|
2564
|
+
? [{ type: 'text', text: content }]
|
|
2565
|
+
: (typeof content === 'object' && content !== null ? [content as Record<string, unknown>] : []))
|
|
2566
|
+
|
|
2567
|
+
const text = rawBlocks
|
|
1424
2568
|
.filter(block => block.type === 'text' && typeof block.text === 'string')
|
|
1425
2569
|
.map(block => block.text as string)
|
|
1426
2570
|
.join('\n')
|
|
1427
2571
|
.trim()
|
|
1428
|
-
|
|
1429
|
-
|
|
2572
|
+
|
|
2573
|
+
const images: PromptImageContent[] = []
|
|
2574
|
+
const contentBlocks: PromptContentBlock[] = []
|
|
2575
|
+
|
|
2576
|
+
for (const block of rawBlocks) {
|
|
2577
|
+
if (!block || typeof block !== 'object') continue
|
|
2578
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
2579
|
+
if (block.text.trim() !== '') {
|
|
2580
|
+
contentBlocks.push({ type: 'text', text: block.text })
|
|
2581
|
+
}
|
|
2582
|
+
} else {
|
|
2583
|
+
const resolved = resolveImageContent(block, context)
|
|
2584
|
+
if (resolved !== undefined) {
|
|
2585
|
+
images.push(resolved)
|
|
2586
|
+
contentBlocks.push(resolved)
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
if (text === '' && images.length === 0) continue
|
|
2592
|
+
|
|
2593
|
+
const item: {
|
|
2594
|
+
text: string
|
|
2595
|
+
id?: string
|
|
2596
|
+
images?: PromptImageContent[]
|
|
2597
|
+
content?: PromptContentBlock[]
|
|
2598
|
+
} = { text, ...(message.id === undefined ? {} : { id: message.id }) }
|
|
2599
|
+
if (images.length > 0) {
|
|
2600
|
+
item.images = images
|
|
2601
|
+
item.content = contentBlocks
|
|
2602
|
+
}
|
|
2603
|
+
last = item
|
|
1430
2604
|
}
|
|
1431
2605
|
if (last !== undefined) return { kind: 'prompt', ...last }
|
|
1432
2606
|
return sawUserRole ? { kind: 'no-user-input' } : { kind: 'empty' }
|
|
@@ -1576,7 +2750,34 @@ export function acpPresetStatusFromRoster(rows: readonly RosterPresetRow[] | und
|
|
|
1576
2750
|
|
|
1577
2751
|
// ---- Transcript ----
|
|
1578
2752
|
|
|
1579
|
-
/**
|
|
2753
|
+
/**
|
|
2754
|
+
* The kind of a state-only tick that consecutive repeats may share one entry.
|
|
2755
|
+
*
|
|
2756
|
+
* These are the frames the client folds to nothing (`STATE_ONLY_UPDATES`):
|
|
2757
|
+
* only their latest value is ever read. `session_info_update` qualifies only
|
|
2758
|
+
* while it carries no title — a title is content the replay must keep, so it
|
|
2759
|
+
* never lets a later tick replace its entry.
|
|
2760
|
+
*/
|
|
2761
|
+
function coalescibleStateKind(update: unknown): string | undefined {
|
|
2762
|
+
if (typeof update !== 'object' || update === null || Array.isArray(update)) return undefined
|
|
2763
|
+
const row = update as Record<string, unknown>
|
|
2764
|
+
const kind = row.sessionUpdate
|
|
2765
|
+
if (kind === 'usage_update' || kind === 'config_option_update') return kind
|
|
2766
|
+
if (kind !== 'session_info_update') return undefined
|
|
2767
|
+
const title = row.title
|
|
2768
|
+
return title === undefined || title === null || title === '' ? kind : undefined
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
/**
|
|
2772
|
+
* Bounded log of raw ACP session updates with monotone seq.
|
|
2773
|
+
*
|
|
2774
|
+
* Consecutive ticks of the same state-only kind share one entry (the entry is
|
|
2775
|
+
* replaced in place, not appended): chatty agents emit a usage / phase tick
|
|
2776
|
+
* every few hundred milliseconds, and letting each occupy the ring evicts the
|
|
2777
|
+
* conversation itself. This costs nothing on the live path — `append` still
|
|
2778
|
+
* allocates a fresh seq and the bridge broadcasts that frame — but replay via
|
|
2779
|
+
* `since` only sees the newest tick of each run.
|
|
2780
|
+
*/
|
|
1580
2781
|
export class Transcript {
|
|
1581
2782
|
private readonly entries: Array<{ seq: number; update: unknown }> = []
|
|
1582
2783
|
private next = 1
|
|
@@ -1585,6 +2786,16 @@ export class Transcript {
|
|
|
1585
2786
|
|
|
1586
2787
|
append(update: unknown): number {
|
|
1587
2788
|
const seq = this.next++
|
|
2789
|
+
const last = this.entries[this.entries.length - 1]
|
|
2790
|
+
const kind = coalescibleStateKind(update)
|
|
2791
|
+
// Replace, don't push: `since(afterSeq)` keeps its filter, so intermediate
|
|
2792
|
+
// seq values are simply absent from replayed entries. Live SSE frames are
|
|
2793
|
+
// broadcast per append (the returned seq), and the client's live fold does
|
|
2794
|
+
// not depend on transcript contiguity — only `head` must keep advancing.
|
|
2795
|
+
if (kind !== undefined && last !== undefined && coalescibleStateKind(last.update) === kind) {
|
|
2796
|
+
this.entries[this.entries.length - 1] = { seq, update }
|
|
2797
|
+
return seq
|
|
2798
|
+
}
|
|
1588
2799
|
this.entries.push({ seq, update })
|
|
1589
2800
|
if (this.entries.length > this.max) this.entries.splice(0, this.entries.length - this.max)
|
|
1590
2801
|
return seq
|
|
@@ -1778,7 +2989,7 @@ export function permissionReplayFrames(pending: readonly PendingPermissionView[]
|
|
|
1778
2989
|
export interface AcpManager {
|
|
1779
2990
|
agentViews(): AgentView[]
|
|
1780
2991
|
startSession(agentId: string, cwd: string | undefined): Promise<{ sessionId: string }>
|
|
1781
|
-
prompt(sessionId: string,
|
|
2992
|
+
prompt(sessionId: string, prompt: string | PromptContentBlock[]): Promise<{ turnId: string }>
|
|
1782
2993
|
cancel(sessionId: string): Promise<void>
|
|
1783
2994
|
closeSession(sessionId: string): Promise<void>
|
|
1784
2995
|
setMode(sessionId: string, modeId: string): Promise<void>
|
|
@@ -2031,7 +3242,37 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
|
|
|
2031
3242
|
case 'session/start':
|
|
2032
3243
|
return ok(await manager.startSession(str(body, 'agentId', 64), optionalStr(body, 'cwd', 4096)))
|
|
2033
3244
|
case 'session/prompt': {
|
|
2034
|
-
|
|
3245
|
+
let promptInput: string | PromptContentBlock[]
|
|
3246
|
+
if (Array.isArray(body.prompt)) {
|
|
3247
|
+
if (body.prompt.length === 0) {
|
|
3248
|
+
throw new AcpError('bad_payload', 'acp: prompt array cannot be empty')
|
|
3249
|
+
}
|
|
3250
|
+
for (const b of body.prompt) {
|
|
3251
|
+
if (!b || typeof b !== 'object') throw new AcpError('bad_payload', 'acp: prompt blocks must be objects')
|
|
3252
|
+
const block = b as Record<string, unknown>
|
|
3253
|
+
if (block.type === 'text') {
|
|
3254
|
+
if (typeof block.text !== 'string') throw new AcpError('bad_payload', 'acp: text block requires string text')
|
|
3255
|
+
if (block.text.length > (1 << 20)) throw new AcpError('bad_payload', 'acp: text block exceeds max length')
|
|
3256
|
+
} else if (block.type === 'image') {
|
|
3257
|
+
if (typeof block.data !== 'string' || typeof block.mimeType !== 'string') {
|
|
3258
|
+
throw new AcpError('bad_payload', 'acp: image block requires string data and mimeType')
|
|
3259
|
+
}
|
|
3260
|
+
if (!block.mimeType.toLowerCase().startsWith('image/')) {
|
|
3261
|
+
throw new AcpError('bad_payload', 'acp: image block mimeType must start with image/')
|
|
3262
|
+
}
|
|
3263
|
+
if (block.uri !== undefined && typeof block.uri !== 'string') {
|
|
3264
|
+
throw new AcpError('bad_payload', 'acp: image block uri must be string')
|
|
3265
|
+
}
|
|
3266
|
+
if (block.data.length > (20 << 20)) throw new AcpError('bad_payload', 'acp: image block exceeds max data length')
|
|
3267
|
+
} else {
|
|
3268
|
+
throw new AcpError('bad_payload', `acp: unsupported prompt block type: ${String(block.type)}`)
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
promptInput = body.prompt as PromptContentBlock[]
|
|
3272
|
+
} else {
|
|
3273
|
+
promptInput = str(body, 'text', 1 << 20)
|
|
3274
|
+
}
|
|
3275
|
+
const { turnId } = await manager.prompt(str(body, 'sessionId', 64), promptInput)
|
|
2035
3276
|
return ok({ accepted: true as const, turnId })
|
|
2036
3277
|
}
|
|
2037
3278
|
case 'session/cancel':
|