Merge remote-tracking branch 'refs/remotes/origin/worktree/web-multimodal-image-input' into worktree/pr555-simplify
# Conflicts: # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md # .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md # apps/cli/README.i18n.yaml # apps/cli/README.md # apps/cli/README.zh.md # apps/cli/src/app-cli-entry.ts # packages/client/connection/src/client/fixture.ts # packages/client/ui-conversation/src/client/service.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/host.schema.ts # packages/host/apiproxy/src/api/host.ts # packages/host/apiproxy/tests/fetch-carrier.spec.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: 3ceb4f56b672170f7a68532718fdd6e249c7b1bc
|
||||
README.zh.md: fdb67792f479b6150f3918db825cfaf6efcd12e5
|
||||
README.md: 6f4d8bf15fb581d184a1bb36c912a88a319adf26
|
||||
README.zh.md: 6ae5291aee8e7e12d78a9c06c2ed9a1432b4f2a0
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation publishes its validated `host.describe` value through `onDescription` before `onConnected`; a business-error response fails the generation like a transport error. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. Each successful generation validates `host.describe` before `onConnected`; a business-error response fails the generation like a transport error. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会先通过 `onDescription` 发布经过校验的 `host.describe` 值,再调用 `onConnected`;业务错误响应会像传输错误一样使该连接代失败。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。每个成功连接代都会在 `onConnected` 前校验 `host.describe`;业务错误响应会像传输错误一样使该连接代失败。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists
|
||||
* these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */
|
||||
@@ -44,8 +44,6 @@ export type ConnectionState = 'connected' | 'reconnecting'
|
||||
export interface ConnectionSinks {
|
||||
onMuxEnvelope?: (envelope: RpcRequest<MuxFrame>) => void
|
||||
onHostEnvelope?: (envelope: RpcRequest<HostFrame>) => void
|
||||
/** Latest successful host capability snapshot for this connection generation. */
|
||||
onDescription?: (description: HostDescription) => void
|
||||
/** After each connection generation is established (both streams open + describe succeeded), first connect included. */
|
||||
onConnected?: () => void
|
||||
/** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect
|
||||
@@ -144,7 +142,6 @@ export class ConnectionController {
|
||||
}
|
||||
if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake')
|
||||
this.attempt = 0
|
||||
this.callSink(() => { this.sinks.onDescription?.(descriptionResult.value) })
|
||||
this.emitState('connected')
|
||||
this.callSink(this.sinks.onConnected)
|
||||
} catch {
|
||||
|
||||
@@ -1260,13 +1260,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
cwd: '/tmp/fixture',
|
||||
provider: 'fixture',
|
||||
model: 'fx-vision',
|
||||
imageLimits: {
|
||||
maxImageBytes: 5 * 1024 * 1024,
|
||||
maxImagesPerMessage: 10,
|
||||
maxMessageImageBytes: 20 * 1024 * 1024,
|
||||
maxImagePixels: 40_000_000,
|
||||
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
|
||||
},
|
||||
attachedSessions,
|
||||
}),
|
||||
// Deterministic native pick: the keyless lanes drive the full
|
||||
|
||||
@@ -23,11 +23,9 @@ describe('connection lifecycle', () => {
|
||||
it('announces connected after describe + both streams open, then pumps frames to sinks', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const muxSeen: string[] = []
|
||||
const descriptions: string[] = []
|
||||
let connected = 0
|
||||
const controller = new ConnectionController(api, {
|
||||
onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type),
|
||||
onDescription: description => descriptions.push(description.version),
|
||||
onConnected: () => { connected++ },
|
||||
}, FAST)
|
||||
controller.start()
|
||||
@@ -36,7 +34,6 @@ describe('connection lifecycle', () => {
|
||||
api.pushMux(subscribedFrame())
|
||||
await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) })
|
||||
expect(api.callsOf('host.describe')).toHaveLength(1)
|
||||
expect(descriptions).toEqual(['0-fake'])
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* explicit act of widening what features may do to the sessions domain.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { HostDescription, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
SessionBinding, SessionListState, SessionProvideDescriptor,
|
||||
@@ -22,11 +22,6 @@ export interface ISessions {
|
||||
readonly list: ObservableSnapshot<SessionListState>
|
||||
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
|
||||
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
|
||||
/**
|
||||
* Read the latest successfully received host capability description.
|
||||
* @returns host capabilities, or undefined before the first successful handshake.
|
||||
*/
|
||||
hostDescription(): HostDescription | undefined
|
||||
/**
|
||||
* Select a session as current.
|
||||
* @param id - session id (must exist in the list; unknown ids fail loud).
|
||||
|
||||
@@ -178,7 +178,6 @@ export function apply(ctx: Context): void {
|
||||
console.error('[web-runtime] history host-frame routing failed:', error)
|
||||
}
|
||||
},
|
||||
onDescription: (description) => { sessions.handleDescription(description) },
|
||||
onConnected: () => {
|
||||
sessions.handleConnected()
|
||||
workspaces.handleConnected()
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
*/
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type {
|
||||
HostDescription, IApiClient, RpcError, SessionId, WorkspaceId,
|
||||
IApiClient, RpcError, SessionId, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
@@ -191,7 +191,6 @@ export class SessionsService implements ISessions {
|
||||
private watched: SessionId | undefined
|
||||
/** Removed-while-staged sessions whose teardown waits for the stage to move away. */
|
||||
private readonly deferredRemovals = new Set<SessionId>()
|
||||
private description: HostDescription | undefined
|
||||
|
||||
/**
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
@@ -231,22 +230,6 @@ export class SessionsService implements ISessions {
|
||||
rootCtx.reflect.provide('sessions', this, undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the latest successful connection-generation host description.
|
||||
* @param description - capability and deployment snapshot from `host.describe`.
|
||||
*/
|
||||
handleDescription(description: HostDescription): void {
|
||||
this.description = description
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the latest host capability snapshot.
|
||||
* @returns the last successful description, or undefined before connection.
|
||||
*/
|
||||
hostDescription(): HostDescription | undefined {
|
||||
return this.description
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a per-session standard-props provider: every session-scope slot
|
||||
* component receives the contributed members as standard props (`hooks`
|
||||
|
||||
@@ -74,12 +74,6 @@ describe('runtime client apply', () => {
|
||||
expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new')
|
||||
// Mux sink and onConnected route without throwing (manager semantics own the behavior).
|
||||
bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never })
|
||||
bench.sinks?.onDescription?.({ version: '0', cwd: '/f', attachedSessions: 0 })
|
||||
expect((sessions as { hostDescription(): unknown }).hostDescription()).toEqual({
|
||||
version: '0',
|
||||
cwd: '/f',
|
||||
attachedSessions: 0,
|
||||
})
|
||||
bench.sinks?.onConnected?.()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
@@ -197,14 +196,6 @@ export class TestSessions implements ISessions {
|
||||
this.list.subscribe(() => { this.channel.publishCurrent() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Test runtime has no host handshake unless a fixture explicitly supplies one.
|
||||
* @returns undefined.
|
||||
*/
|
||||
hostDescription(): HostDescription | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a session from a fixture and (by default) make it current.
|
||||
* @param fixture - identity + snapshot/summary overrides + behavior face.
|
||||
|
||||
@@ -472,8 +472,6 @@ describe('fixture session face', () => {
|
||||
expect(() => bare.command()).toThrow(/command is not stubbed/)
|
||||
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
|
||||
expect(() => bare.rename()).toThrow(/rename is not stubbed/)
|
||||
// No host handshake exists in the bench unless a fixture supplies one.
|
||||
expect(runtime.sessions.hostDescription()).toBeUndefined()
|
||||
await runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -196,9 +196,9 @@ export function apply(ctx: Context): void {
|
||||
const shell = inputHub.shell(sessionId)
|
||||
return {
|
||||
keyboard: shell,
|
||||
addImages: (files, current) => {
|
||||
addImages: (files) => {
|
||||
try {
|
||||
const images = conversation.createDraftImages(files, current)
|
||||
const images = conversation.createDraftImages(files)
|
||||
shell.addImages(images.map(image => image.id))
|
||||
return null
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -273,7 +273,7 @@ export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
|
||||
keyboard: ComposerKeyboard
|
||||
/** Create browser previews and append their ids to the session input state. */
|
||||
addImages: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null
|
||||
addImages: (files: readonly File[]) => string | null
|
||||
/** Release one browser preview and remove its id from the session input state. */
|
||||
removeImage: (id: string) => void
|
||||
/** Resolve ordered input-state ids to browser-owned draft attachments. */
|
||||
|
||||
@@ -123,7 +123,6 @@ export class ConversationService extends Service implements IConversation {
|
||||
mode: 'queue' | 'steer',
|
||||
images: readonly File[],
|
||||
): Promise<void> {
|
||||
this.validateImages(images, [])
|
||||
const uploaded = await this.serializeImages(images)
|
||||
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
|
||||
const result = await session.prompt(content, mode)
|
||||
@@ -133,14 +132,10 @@ export class ConversationService extends Service implements IConversation {
|
||||
/**
|
||||
* Create runtime-only draft attachments and their object URLs.
|
||||
* @param files - browser-owned image files.
|
||||
* @param current - images already present in the same composer.
|
||||
* @returns ordered attachment descriptors whose ids may enter the input state.
|
||||
*/
|
||||
createDraftImages(
|
||||
files: readonly File[],
|
||||
current: readonly ComposerAttachment[] = [],
|
||||
): readonly ComposerAttachment[] {
|
||||
this.validateImages(files, current)
|
||||
createDraftImages(files: readonly File[]): readonly ComposerAttachment[] {
|
||||
for (const file of files) imageMediaType(file.type)
|
||||
return files.map((file) => {
|
||||
const attachment = browserDraftAttachment(file)
|
||||
this.draftAttachments.set(attachment.id, attachment)
|
||||
@@ -276,36 +271,6 @@ export class ConversationService extends Service implements IConversation {
|
||||
return sessions
|
||||
}
|
||||
|
||||
/** Apply host-advertised fast-path checks before any object URL or base64 allocation. */
|
||||
private validateImages(
|
||||
files: readonly File[],
|
||||
current: readonly ComposerAttachment[],
|
||||
): void {
|
||||
if (files.length === 0 && current.length === 0) return
|
||||
// Model capability is checked only by the host against the session's
|
||||
// current target; the client owns deployment upload limits.
|
||||
const description = this.requireSessions().hostDescription()
|
||||
const limits = description?.imageLimits
|
||||
const all = [...current.map(attachment => attachment.file), ...files]
|
||||
if (limits !== undefined && all.length > limits.maxImagesPerMessage) {
|
||||
throw new Error(`每条消息最多添加 ${limits.maxImagesPerMessage} 张图片`)
|
||||
}
|
||||
let totalBytes = 0
|
||||
for (const file of all) {
|
||||
const mediaType = imageMediaType(file.type)
|
||||
if (limits !== undefined && !limits.mediaTypes.includes(mediaType)) {
|
||||
throw new Error(`当前部署不支持 ${mediaType} 图片`)
|
||||
}
|
||||
if (limits !== undefined && file.size > limits.maxImageBytes) {
|
||||
throw new Error(`图片 ${file.name || '未命名图片'} 超过单张大小限制`)
|
||||
}
|
||||
totalBytes += file.size
|
||||
}
|
||||
if (limits !== undefined && totalBytes > limits.maxMessageImageBytes) {
|
||||
throw new Error('图片总大小超过单条消息限制')
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert browser files to the prompt wire's canonical base64 image parts. */
|
||||
private serializeImages(images: readonly File[]): Promise<Parameters<SessionFace['prompt']>[0]> {
|
||||
return Promise.all(images.map(async file => ({
|
||||
|
||||
@@ -234,7 +234,7 @@ export function InputBar({
|
||||
.map(item => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
if (files.length > 0) {
|
||||
setDropError(addImages(files, attachments))
|
||||
setDropError(addImages(files))
|
||||
}
|
||||
const text = e.clipboardData.getData('text/plain')
|
||||
if (text === '') {
|
||||
@@ -290,7 +290,7 @@ export function InputBar({
|
||||
if (locked || machineBusy) return
|
||||
const dropped = [...event.dataTransfer.files]
|
||||
if (dropped.length === 0) return
|
||||
setDropError(addImages(dropped, attachments))
|
||||
setDropError(addImages(dropped))
|
||||
}
|
||||
|
||||
const closePreview = useCallback(() => { setPreview(null) }, [])
|
||||
|
||||
@@ -49,7 +49,7 @@ interface BenchOptions {
|
||||
leftItems?: React.ReactNode
|
||||
rightItems?: React.ReactNode
|
||||
attachments?: readonly ComposerAttachment[]
|
||||
addImages?: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null
|
||||
addImages?: (files: readonly File[]) => string | null
|
||||
}
|
||||
|
||||
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
|
||||
@@ -464,7 +464,7 @@ describe('image draft rail', () => {
|
||||
getData: () => '同时粘贴的文字',
|
||||
},
|
||||
})
|
||||
expect(addImages).toHaveBeenCalledWith([image], [])
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
expect(shell.snapshot.draft).toBe('同时粘贴的文字')
|
||||
|
||||
const video = new File([Uint8Array.of(1)], 'clip.mp4', { type: 'video/mp4' })
|
||||
@@ -494,7 +494,7 @@ describe('image draft rail', () => {
|
||||
expect(dataTransfer.dropEffect).toBe('copy')
|
||||
expect(fireEvent.drop(card, { dataTransfer })).toBe(false)
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
expect(addImages).toHaveBeenCalledWith([image], [])
|
||||
expect(addImages).toHaveBeenCalledWith([image])
|
||||
})
|
||||
|
||||
it('ignores unsupported dropped files and refuses drops while locked', () => {
|
||||
@@ -507,7 +507,7 @@ describe('image draft rail', () => {
|
||||
dataTransfer: { types: ['Files'], files: [documentFile], dropEffect: 'none' },
|
||||
})
|
||||
expect(view.getByText(/不支持的图片格式/)).toBeTruthy()
|
||||
expect(addImages).toHaveBeenCalledWith([documentFile], [])
|
||||
expect(addImages).toHaveBeenCalledWith([documentFile])
|
||||
|
||||
const image = new File([Uint8Array.of(1)], 'locked.png', { type: 'image/png' })
|
||||
const locked = bench({ disabled: true, addImages })
|
||||
|
||||
@@ -49,47 +49,6 @@ describe('ConversationService', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('accepts ordered batches and preflights their advertised count and aggregate limits', async () => {
|
||||
const b = await bench()
|
||||
const described = vi.spyOn(b.runtime.sessions, 'hostDescription').mockReturnValue({
|
||||
version: 'test',
|
||||
cwd: '/tmp',
|
||||
imageLimits: {
|
||||
maxImageBytes: 3,
|
||||
maxImagesPerMessage: 2,
|
||||
maxMessageImageBytes: 3,
|
||||
maxImagePixels: 4,
|
||||
mediaTypes: ['image/png'],
|
||||
},
|
||||
attachedSessions: 1,
|
||||
})
|
||||
const created = vi.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValueOnce('blob:first')
|
||||
.mockReturnValueOnce('blob:second')
|
||||
const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined)
|
||||
try {
|
||||
const attachments = b.root.createDraftImages([
|
||||
new File([Uint8Array.of(1)], 'first.png', { type: 'image/png' }),
|
||||
new File([Uint8Array.of(2)], 'second.png', { type: 'image/png' }),
|
||||
])
|
||||
expect(attachments.map(attachment => attachment.file.name)).toEqual(['first.png', 'second.png'])
|
||||
expect(() => b.root.createDraftImages([
|
||||
new File([Uint8Array.of(3)], 'third.png', { type: 'image/png' }),
|
||||
], attachments)).toThrow('每条消息最多添加 2 张图片')
|
||||
const first = attachments[0]
|
||||
if (first === undefined) throw new Error('first draft attachment missing')
|
||||
expect(() => b.root.createDraftImages([
|
||||
new File([Uint8Array.of(3, 4, 5)], 'large.png', { type: 'image/png' }),
|
||||
], [first])).toThrow('图片总大小超过单条消息限制')
|
||||
expect(created).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
await b.runtime.dispose()
|
||||
described.mockRestore()
|
||||
created.mockRestore()
|
||||
revoked.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('releases draft images when the session scope is disposed', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1')
|
||||
@@ -110,6 +69,31 @@ describe('ConversationService', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('checks media type before preview allocation and leaves deployment limits to the host', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockImplementation(file => `blob:${(file as File).name}`)
|
||||
try {
|
||||
const files = Array.from(
|
||||
{ length: 11 },
|
||||
(_, index) => new File([Uint8Array.of(index)], `${index}.png`, { type: 'image/png' }),
|
||||
)
|
||||
expect(b.root.createDraftImages(files)).toHaveLength(11)
|
||||
expect(created).toHaveBeenCalledTimes(11)
|
||||
|
||||
const beforeRejectedBatch = created.mock.calls.length
|
||||
expect(() => {
|
||||
b.root.createDraftImages([
|
||||
new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }),
|
||||
new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }),
|
||||
])
|
||||
}).toThrow('不支持的图片格式:image/svg+xml')
|
||||
expect(created).toHaveBeenCalledTimes(beforeRejectedBatch)
|
||||
} finally {
|
||||
created.mockRestore()
|
||||
}
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('releases in-flight send images when the scope dies before the failure lands', async () => {
|
||||
const b = await bench()
|
||||
const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:inflight-1')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
|
||||
README.md: 4838be893c1d5422cc707cb0d7542a056be41fa7
|
||||
README.zh.md: ed582128246a62297f555f8abe09f427cb9d256a
|
||||
README.md: 2cb382f53466c07b977eef4d5a1ef2804c13abea
|
||||
README.zh.md: 2fc30da5e4c895027ab9dea78e9e3f86890cafc1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. The host returns the intersection of model-invocable and user-invocable skills because this browser path lets a user insert a model reference rather than loading the body directly. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
|
||||
|
||||
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑,host 从会话 header 解析 `cwd`。目录按会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
skill(技能)引用 source 的浏览器端:把 `/` 触发的 `skill` source 注册进 `ctx.slash`。候选来自 `skill.list` RPC,以每次调用的 `ClientSessionContext` 投影中的 `{sessionId}` 寻址——每个会话始终由 agent(智能体)支撑,host 从会话 header 解析 `cwd`。宿主返回模型可调用与用户可调用 skill 的交集,因为该浏览器路径让用户插入模型引用,而不是直接加载正文。目录按会话缓存,拉取走 single-flight;scope 创建时的 `warm` 钩子预热该会话的缓存项,`connection/reset` 清空全部缓存。结果按 `startsWith(query)` 过滤;pick 一个候选会把字面文本 `/name ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 拥有该引用的两种投影:`clipboardText` → `/name`,`serialize` → 提交时生成的模型形式 `<skill>name</skill>`。RPC 使用插件注册时捕获的根上下文连接——source 绝不从每次调用的参数上读取服务。source 不实现 `matchSpace`/`matchEnter` 钩子——skill 引用永不进入命令裁决,随普通提示词落入 default sink。
|
||||
|
||||
`skill.list` 失败时 `candidates` 抛出异常,slash 壳层记录日志并折叠为静默的菜单组丢弃——菜单只显示 pending/ready 状态。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user