Files
deepseek-harness/packages/client/ui-feedback/src/client/controller.ts
T
Chinesezjc b462d5fd69 Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui
Adapt to two contract changes master introduced:

- The generated Remote face now wraps every business result in
  RemoteResult, folding carrier failures into an ok:false branch instead
  of rejecting. The controller reads that envelope at its three call
  sites and maps a carrier failure onto the same settled shape the
  controls already render; three specs cover the new branch.
- Client packages split their tsconfig into host and client halves, and
  the host aggregate now compiles any test not named *.client.spec.*.
  Rename this package's specs to the client convention and drop the
  ../connection project reference, which pointed at a solution file that
  no longer carries the client sources.

Keep master's mount loop with its rollback-on-failure in api-remotes and
add messageFeedbackRemote to it.
2026-08-12 10:43:23 +08:00

378 lines
14 KiB
TypeScript

/**
* Browser-local object layer over one Session's durable message-feedback
* sidecar. The Host owns per-item compare-and-set: every mutation carries the
* version this controller last observed, and a `version-conflict` reply carries
* the authoritative item, so a lost race reconciles from the reply itself
* instead of refetching the whole Session.
* @module @deepseek-ai/dsh-client-ui-feedback/client/controller
*/
import type { RemoteResult } from '@deepseek-ai/dsh-type-meta'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type {
MessageFeedbackDeleteResult,
MessageFeedbackItem,
MessageFeedbackListResult,
MessageFeedbackPutResult,
MessageFeedbackRating,
} from '@deepseek-ai/dsh-message-feedback/types'
/**
* The three Remote calls this controller needs. The generated face wraps every
* business result in {@link RemoteResult}: a carrier failure arrives as the
* `ok: false` branch rather than a rejection, so this controller reads one
* envelope and never wraps a call to recover a transport error.
*/
export interface MessageFeedbackRemote {
list: (request: { sessionId: SessionId }) => Promise<RemoteResult<MessageFeedbackListResult>>
put: (request: {
sessionId: SessionId
messageId: MessageId
rating: MessageFeedbackRating
note?: string
ifVersion: MessageFeedbackItem['version'] | null
}) => Promise<RemoteResult<MessageFeedbackPutResult>>
delete: (request: {
sessionId: SessionId
messageId: MessageId
ifVersion: MessageFeedbackItem['version']
}) => Promise<RemoteResult<MessageFeedbackDeleteResult>>
}
/** Load state of the one list read that seeds every per-message control. */
export type FeedbackStatus = 'cold' | 'loading' | 'ready' | 'error'
/** Immutable view published to every per-message control in one Session. */
export interface FeedbackView {
status: FeedbackStatus
/** Current item per message, keyed by the addressed message id. */
items: ReadonlyMap<MessageId, MessageFeedbackItem>
/** Reason the last load failed, cleared by the next successful load. */
error: string | null
}
/** Settled action shape rendered by the message-level controls. */
export type FeedbackActionResult =
| { ok: true }
| { ok: false; error: { code: string; message: string } }
// `Object.freeze` does not protect a Map: `set`/`delete` write internal slots,
// not properties. Immutability here is by discipline instead — the view type is
// ReadonlyMap and every publish hands over a freshly built Map that this class
// keeps no mutable reference to.
const EMPTY_ITEMS: ReadonlyMap<MessageId, MessageFeedbackItem> = new Map()
const INITIAL_VIEW: FeedbackView = Object.freeze({
status: 'cold',
items: EMPTY_ITEMS,
error: null,
})
const OK: FeedbackActionResult = Object.freeze({ ok: true })
const DISPOSED: FeedbackActionResult = Object.freeze({
ok: false,
error: Object.freeze({ code: 'disposed', message: 'feedback controller is disposed' }),
})
/** Human-readable text for one business failure code. */
function describe(code: string): string {
switch (code) {
case 'session-not-found': return 'this session is no longer persisted'
case 'target-not-found': return 'this message is not a persisted assistant message'
case 'version-conflict': return 'feedback changed elsewhere'
case 'note-blank': return 'a note must contain a non-whitespace character'
case 'note-too-large': return 'the note is too long'
default: return code
}
}
/** Build the rejected branch for one business failure code. */
function fail(code: string): FeedbackActionResult {
return { ok: false, error: { code, message: describe(code) } }
}
/** Carrier failure rendered with the Host-supplied code and message. */
function carrierFailure(error: { code: string; message: string }): FeedbackActionResult {
return { ok: false, error: { code: error.code, message: error.message } }
}
/**
* Per-session feedback object layer. One instance backs every per-message
* control in that Session, so a single list read seeds them all.
*/
export class FeedbackController implements HostObservable<FeedbackView> {
private view = INITIAL_VIEW
private readonly listeners = new Set<() => void>()
private loadPromise: Promise<FeedbackActionResult> | null = null
private operationTail: Promise<void> = Promise.resolve()
private disposed = false
/**
* @param remote - the messageFeedback Remote namespace.
* @param sessionId - Session owning every addressed assistant message.
*/
constructor(
private readonly remote: MessageFeedbackRemote,
private readonly sessionId: SessionId,
) {}
/** Return the cached immutable view. */
getSnapshot = (): FeedbackView => this.view
/** Subscribe to view replacement. */
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* Load once; a failed load stays retryable.
* @returns the settled load result, shared by concurrent callers.
*/
ensure(): Promise<FeedbackActionResult> {
if (this.view.status === 'ready') return Promise.resolve(OK)
return this.refresh()
}
/**
* Re-read the authoritative list, collapsing concurrent callers onto one
* in-flight read.
*
* This is the unserialized read used to seed a cold controller, where no
* mutation can be in flight yet. A reconnect must use {@link resync} instead:
* an unserialized list response can otherwise arrive after a newer mutation's
* reply and overwrite the version that mutation just committed.
* @returns the settled reload result.
*/
refresh(): Promise<FeedbackActionResult> {
if (this.loadPromise !== null) return this.loadPromise
this.publish({ status: 'loading', items: this.view.items, error: null })
const pending = this.load()
this.loadPromise = pending
return pending.finally(() => { this.loadPromise = null })
}
/**
* Re-read the list behind this Session's queued mutations, so a reconnect
* cannot resurrect a version an in-flight mutation already replaced.
* @returns the settled reload result.
*/
resync(): Promise<FeedbackActionResult> {
// seed: false — this operation *is* the read, so pre-seeding would either
// short-circuit it (status already ready) or run it twice.
return this.mutate(() => this.refresh(), { seed: false })
}
/**
* Create or replace feedback for one message, comparing against the version
* this controller last observed.
*
* The note is resolved here rather than by the caller: `mutate` awaits the
* one list read first, so this body always sees the committed item, while a
* control that rendered before that read completed would still be holding
* `undefined`. Omitting `note` therefore keeps whatever is stored; only
* {@link clearNote} removes one.
* @param messageId - target assistant message.
* @param rating - desired judgment.
* @param note - replacement explanation; omitted keeps the stored note.
* @returns the settled mutation result.
*/
rate(
messageId: MessageId,
rating: MessageFeedbackRating,
note?: string,
): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
return await this.putCommitted(messageId, rating, note ?? observed?.note, observed)
})
}
/**
* Replace one message's rating with the opposite judgment, or retract it when
* the committed rating already matches. The decision reads the committed item
* inside the serialized mutation, so a click that lands before the first list
* read still toggles against the stored value rather than the empty view a
* cold control rendered.
* @param messageId - target assistant message.
* @param rating - the judgment the human asked for.
* @returns the settled mutation result.
*/
toggle(messageId: MessageId, rating: MessageFeedbackRating): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
if (observed?.rating === rating) return await this.deleteCommitted(messageId, observed)
return await this.putCommitted(messageId, rating, observed?.note, observed)
})
}
/**
* Drop the note while keeping the rating. Absent feedback needs no call.
* @param messageId - target assistant message.
* @returns the settled mutation result.
*/
clearNote(messageId: MessageId): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
if (observed === undefined || observed.note === undefined) return OK
return await this.putCommitted(messageId, observed.rating, undefined, observed)
})
}
/**
* Remove feedback for one message. A message with no known item is already
* in the requested state, so no call is made.
* @param messageId - target assistant message.
* @returns the settled mutation result.
*/
clear(messageId: MessageId): Promise<FeedbackActionResult> {
return this.mutate(async () => {
const observed = this.view.items.get(messageId)
if (observed === undefined) return OK
return await this.deleteCommitted(messageId, observed)
})
}
/** Commit one put against the observed version and reconcile a conflict. */
private async putCommitted(
messageId: MessageId,
rating: MessageFeedbackRating,
note: string | undefined,
observed: MessageFeedbackItem | undefined,
): Promise<FeedbackActionResult> {
const carried = await this.remote.put({
sessionId: this.sessionId,
messageId,
rating,
...(note === undefined ? {} : { note }),
ifVersion: observed?.version ?? null,
})
if (!carried.ok) return carrierFailure(carried.error)
const result = carried.value
if (result.ok) {
this.commit(messageId, result.value)
return OK
}
if (result.error.code === 'version-conflict') this.commit(messageId, result.error.current)
return fail(result.error.code)
}
/** Commit one delete against the observed version and reconcile a conflict. */
private async deleteCommitted(
messageId: MessageId,
observed: MessageFeedbackItem,
): Promise<FeedbackActionResult> {
const carried = await this.remote.delete({
sessionId: this.sessionId,
messageId,
ifVersion: observed.version,
})
if (!carried.ok) return carrierFailure(carried.error)
const result = carried.value
if (result.ok) {
this.commit(messageId, null)
return OK
}
if (result.error.code === 'version-conflict') this.commit(messageId, result.error.current)
return fail(result.error.code)
}
/** Drop subscribers and refuse further work when the owning fiber unloads. */
dispose(): void {
this.disposed = true
this.listeners.clear()
}
/** Fetch the whole sidecar and publish it as the seeded view. */
private async load(): Promise<FeedbackActionResult> {
try {
const carried = await this.remote.list({ sessionId: this.sessionId })
if (this.disposed) return OK
if (!carried.ok) {
this.publish({ status: 'error', items: this.view.items, error: carried.error.message })
return carrierFailure(carried.error)
}
const result = carried.value
if (!result.ok) {
this.publish({ status: 'error', items: this.view.items, error: describe(result.error.code) })
return fail(result.error.code)
}
const items = new Map<MessageId, MessageFeedbackItem>()
for (const item of result.value.items) items.set(item.messageId, item)
this.publish({ status: 'ready', items, error: null })
return OK
} catch (error) {
if (this.disposed) return OK
const message = error instanceof Error ? error.message : 'message feedback list failed'
this.publish({ status: 'error', items: this.view.items, error: message })
return { ok: false, error: { code: 'transport', message } }
}
}
/**
* Serialize one mutation behind this Session's prior mutation so queued
* operations always compare against the committed version, and translate a
* transport throw into the same settled shape the controls already render.
*/
private mutate(
operation: () => Promise<FeedbackActionResult>,
options: { readonly seed?: boolean } = {},
): Promise<FeedbackActionResult> {
const guarded = async (): Promise<FeedbackActionResult> => {
if (this.disposed) return DISPOSED
if (options.seed !== false) {
const loaded = await this.ensure()
if (!loaded.ok) return loaded
// Disposal can land while the seeding read is in flight; without this
// second check the fiber would still reach the wire after unloading.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- dispose() can run during the await.
if (this.disposed) return DISPOSED
}
try {
return await operation()
} catch (error) {
return {
ok: false,
error: {
code: 'transport',
message: error instanceof Error ? error.message : 'message feedback mutation failed',
},
}
}
}
const result = this.operationTail.then(guarded, guarded)
// `guarded` settles every carrier and business failure as a
// FeedbackActionResult and never rethrows, so this tail cannot reject and
// needs no rejection handler.
this.operationTail = result.then(() => undefined)
return result
}
/**
* Replace one message's entry, keeping every other entry's identity. Only a
* `mutate` operation reaches this, and `mutate` refuses admission once the
* controller is disposed, so no disposal guard belongs here; `publish` is
* the single place that stops notifying after listeners are dropped.
*/
private commit(messageId: MessageId, item: MessageFeedbackItem | null): void {
const items = new Map(this.view.items)
if (item === null) items.delete(messageId)
else items.set(messageId, item)
this.publish({ status: 'ready', items, error: null })
}
/** Replace the view and contain subscriber failures at the observable boundary. */
private publish(view: FeedbackView): void {
this.view = Object.freeze(view)
for (const listener of this.listeners) {
try {
listener()
} catch (error) {
console.error('[ui-feedback] subscriber threw:', error)
}
}
}
}