refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
/* Per-message feedback controls. The rating buttons mirror the shared message
|
||||
IconActions chrome so the strip reads as one row; the note editor is an
|
||||
inline expansion anchored to the same row. */
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* A recorded rating stays legible without hover, so the signal survives a
|
||||
pointer leaving the row. */
|
||||
.action[data-active] {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.noteOpen {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 28px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.noteOpen:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.noteEditor {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.noteInput {
|
||||
width: 260px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--dsw-alias-border-secondary);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-primary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.noteSave,
|
||||
.noteCancel {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.noteSave {
|
||||
background: var(--dsw-alias-interactive-bg-primary);
|
||||
color: var(--dsw-alias-label-inverse);
|
||||
}
|
||||
|
||||
.noteSave:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.noteCancel {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.noteCancel:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.failure {
|
||||
padding-left: 4px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 28px;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Per-message feedback controls: a Like/Dislike pair plus an optional note.
|
||||
* Rendered inside the assistant message's IconActions row, so the buttons
|
||||
* reuse that row's chrome and sit between copy and branch.
|
||||
* @module @deepseek-ai/dsh-client-ui-message-feedback/client/MessageFeedbackActions
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
IconDislikeOutline16, IconLikeOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
|
||||
import type { MessageFeedbackActionProps } from './slots.ts'
|
||||
import css from './MessageFeedbackActions.module.css'
|
||||
|
||||
/**
|
||||
* One message's feedback controls.
|
||||
* @param props - the owner's message identity, the injected verbs, and the
|
||||
* shared feedback hook.
|
||||
* @returns the rating buttons, plus the note editor while it is open.
|
||||
*/
|
||||
export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearNote, useFeedback, t }: MessageFeedbackActionProps) {
|
||||
const item = useFeedback(view => view.items.get(messageId))
|
||||
const loadFailed = useFeedback(view => view.status === 'error')
|
||||
const rating = item?.rating
|
||||
const [noteOpen, setNoteOpen] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
const [pending, setPending] = useState(false)
|
||||
const [failure, setFailure] = useState<string | null>(null)
|
||||
// The controls mount for every settled message in the transcript, so the
|
||||
// Session's feedback is read once on first hover/focus rather than on mount.
|
||||
const seeded = useRef(false)
|
||||
const seed = useCallback(() => {
|
||||
if (seeded.current) return
|
||||
seeded.current = true
|
||||
void ensure()
|
||||
}, [ensure])
|
||||
|
||||
const alive = useRef(true)
|
||||
useEffect(() => () => { alive.current = false }, [])
|
||||
|
||||
const settle = useCallback((result: { ok: boolean; error?: { code: string } }) => {
|
||||
if (!alive.current) return
|
||||
setPending(false)
|
||||
if (result.ok) {
|
||||
setFailure(null)
|
||||
return
|
||||
}
|
||||
setFailure(result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic'))
|
||||
}, [t])
|
||||
|
||||
const onRate = useCallback((next: MessageFeedbackRating) => {
|
||||
setPending(true)
|
||||
setFailure(null)
|
||||
// The controller decides retract-vs-replace from the committed item, so a
|
||||
// click that lands before the first list read still toggles the stored
|
||||
// value instead of this render's empty view.
|
||||
setNoteOpen(false)
|
||||
void toggle(messageId, next).then(settle)
|
||||
}, [messageId, settle, toggle])
|
||||
|
||||
// The rating is a parameter because only the note editor's render site can
|
||||
// prove one is recorded; that removes an unreachable undefined guard here.
|
||||
const onSaveNote = useCallback((current: MessageFeedbackRating) => {
|
||||
const trimmed = draft.trim()
|
||||
setPending(true)
|
||||
setFailure(null)
|
||||
// An emptied editor removes the note explicitly; `rate` alone preserves a
|
||||
// stored note, so it cannot express deletion.
|
||||
const settled = trimmed.length === 0
|
||||
? clearNote(messageId)
|
||||
: rate(messageId, current, trimmed)
|
||||
void settled.then((result) => {
|
||||
settle(result)
|
||||
if (result.ok && alive.current) setNoteOpen(false)
|
||||
})
|
||||
}, [clearNote, draft, messageId, rate, settle])
|
||||
|
||||
const openNote = useCallback(() => {
|
||||
setDraft(item?.note ?? '')
|
||||
setNoteOpen(true)
|
||||
}, [item?.note])
|
||||
|
||||
const likeLabel = rating === 'positive' ? t('action.likeActive') : t('action.like')
|
||||
const dislikeLabel = rating === 'negative' ? t('action.dislikeActive') : t('action.dislike')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={likeLabel} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={likeLabel}
|
||||
aria-pressed={rating === 'positive'}
|
||||
data-active={rating === 'positive' || undefined}
|
||||
disabled={pending}
|
||||
onFocus={seed}
|
||||
onPointerEnter={seed}
|
||||
onClick={() => { onRate('positive') }}
|
||||
>
|
||||
<IconLikeOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={dislikeLabel} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={dislikeLabel}
|
||||
aria-pressed={rating === 'negative'}
|
||||
data-active={rating === 'negative' || undefined}
|
||||
disabled={pending}
|
||||
onFocus={seed}
|
||||
onPointerEnter={seed}
|
||||
onClick={() => { onRate('negative') }}
|
||||
>
|
||||
<IconDislikeOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{rating !== undefined && !noteOpen && (
|
||||
<button type="button" className={css.noteOpen} onClick={openNote}>
|
||||
{item?.note === undefined ? t('note.open') : item.note}
|
||||
</button>
|
||||
)}
|
||||
{rating !== undefined && noteOpen && (
|
||||
<span className={css.noteEditor}>
|
||||
<textarea
|
||||
className={css.noteInput}
|
||||
aria-label={t('note.aria')}
|
||||
placeholder={t('note.placeholder')}
|
||||
value={draft}
|
||||
rows={2}
|
||||
onChange={(event) => { setDraft(event.target.value) }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.noteSave}
|
||||
disabled={pending}
|
||||
onClick={() => { onSaveNote(rating) }}
|
||||
>
|
||||
{t('note.save')}
|
||||
</button>
|
||||
<button type="button" className={css.noteCancel} onClick={() => { setNoteOpen(false) }}>
|
||||
{t('note.cancel')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{failure === null && loadFailed && (
|
||||
<span className={css.failure} role="status">{t('error.load')}</span>
|
||||
)}
|
||||
{failure !== null && <span className={css.failure} role="status">{failure}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
377
packages/client/ui-message-feedback/src/client/controller.ts
Normal file
377
packages/client/ui-message-feedback/src/client/controller.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* 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-message-feedback/client/controller
|
||||
*/
|
||||
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
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 MessageFeedbackStatus = 'cold' | 'loading' | 'ready' | 'error'
|
||||
|
||||
/** Immutable view published to every per-message control in one Session. */
|
||||
export interface MessageFeedbackView {
|
||||
status: MessageFeedbackStatus
|
||||
/** 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 MessageFeedbackActionResult =
|
||||
| { 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: MessageFeedbackView = Object.freeze({
|
||||
status: 'cold',
|
||||
items: EMPTY_ITEMS,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const OK: MessageFeedbackActionResult = Object.freeze({ ok: true })
|
||||
|
||||
const DISPOSED: MessageFeedbackActionResult = 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): MessageFeedbackActionResult {
|
||||
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 }): MessageFeedbackActionResult {
|
||||
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 MessageFeedbackController implements HostObservable<MessageFeedbackView> {
|
||||
private view = INITIAL_VIEW
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private loadPromise: Promise<MessageFeedbackActionResult> | 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 = (): MessageFeedbackView => 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<MessageFeedbackActionResult> {
|
||||
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<MessageFeedbackActionResult> {
|
||||
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<MessageFeedbackActionResult> {
|
||||
// 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<MessageFeedbackActionResult> {
|
||||
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<MessageFeedbackActionResult> {
|
||||
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<MessageFeedbackActionResult> {
|
||||
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<MessageFeedbackActionResult> {
|
||||
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<MessageFeedbackActionResult> {
|
||||
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<MessageFeedbackActionResult> {
|
||||
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<MessageFeedbackActionResult> {
|
||||
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<MessageFeedbackActionResult>,
|
||||
options: { readonly seed?: boolean } = {},
|
||||
): Promise<MessageFeedbackActionResult> {
|
||||
const guarded = async (): Promise<MessageFeedbackActionResult> => {
|
||||
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
|
||||
// MessageFeedbackActionResult 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: MessageFeedbackView): void {
|
||||
this.view = Object.freeze(view)
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener()
|
||||
} catch (error) {
|
||||
console.error('[ui-message-feedback] subscriber threw:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
packages/client/ui-message-feedback/src/client/index.ts
Normal file
84
packages/client/ui-message-feedback/src/client/index.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Message feedback plugin, browser half: the Like/Dislike entry in the
|
||||
* conversation.chat.assistant-actions strip. One MessageFeedbackController per
|
||||
* Session backs every message control in that Session, so a single list read
|
||||
* seeds the whole transcript. Mutations go through the generated
|
||||
* messageFeedback Remote; the Host owns per-item compare-and-set.
|
||||
* @module @deepseek-ai/dsh-client-ui-message-feedback/client
|
||||
*/
|
||||
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary.
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the assistant-actions entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { MessageFeedbackController } from './controller.ts'
|
||||
import { MessageFeedbackActions } from './MessageFeedbackActions.tsx'
|
||||
import type { MessageFeedbackInjected } from './slots.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
export type {
|
||||
MessageFeedbackActionResult, MessageFeedbackStatus, MessageFeedbackView, MessageFeedbackRemote,
|
||||
} from './controller.ts'
|
||||
export type { MessageFeedbackActionProps, MessageFeedbackInjected } from './slots.ts'
|
||||
export type { MessageFeedbackKey } from './locales.ts'
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'feedback'
|
||||
|
||||
/** Required services: the slot registry, the Remote namespace, and the copy. */
|
||||
export const inject = ['slots', 'remote', 'remote.messageFeedback', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: the per-message feedback entry and its per-session
|
||||
* object layer.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-message-feedback: dictionaries')
|
||||
|
||||
const controllers = new Map<SessionId, MessageFeedbackController>()
|
||||
const controllerFor = (sessionId: SessionId): MessageFeedbackController => {
|
||||
let controller = controllers.get(sessionId)
|
||||
if (controller === undefined) {
|
||||
controller = new MessageFeedbackController(ctx.remote.messageFeedback, sessionId)
|
||||
controllers.set(sessionId, controller)
|
||||
}
|
||||
return controller
|
||||
}
|
||||
|
||||
// A reconnect can only invalidate what was already read; a cold Session
|
||||
// stays cold until something asks for it.
|
||||
ctx.on('connection/reset', () => {
|
||||
for (const controller of controllers.values()) {
|
||||
if (controller.getSnapshot().status !== 'cold') void controller.resync()
|
||||
}
|
||||
})
|
||||
|
||||
ctx.slots.inject('conversation.chat.assistant-actions', () => {
|
||||
const dispose = ctx.slots.register({
|
||||
name: 'conversation.chat.assistant-actions',
|
||||
id: 'feedback',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: (sessionId): MessageFeedbackInjected => {
|
||||
const controller = controllerFor(sessionId)
|
||||
return {
|
||||
hooks: { feedback: controller },
|
||||
ensure: () => controller.ensure(),
|
||||
rate: (messageId, rating, note) => controller.rate(messageId, rating, note),
|
||||
toggle: (messageId, rating) => controller.toggle(messageId, rating),
|
||||
clearNote: messageId => controller.clearNote(messageId),
|
||||
clear: messageId => controller.clear(messageId),
|
||||
}
|
||||
},
|
||||
}, MessageFeedbackActions)
|
||||
return () => {
|
||||
dispose()
|
||||
for (const controller of controllers.values()) controller.dispose()
|
||||
controllers.clear()
|
||||
}
|
||||
})
|
||||
}
|
||||
43
packages/client/ui-message-feedback/src/client/locales.ts
Normal file
43
packages/client/ui-message-feedback/src/client/locales.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/** `feedback` namespace dictionaries. */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'action.like': '好的回答',
|
||||
'action.likeActive': '取消标记',
|
||||
'action.dislike': '有问题的回答',
|
||||
'action.dislikeActive': '取消标记',
|
||||
'note.open': '补充说明',
|
||||
'note.placeholder': '这条回答哪里好,或哪里有问题?(可选)',
|
||||
'note.save': '保存',
|
||||
'note.cancel': '取消',
|
||||
'note.aria': '反馈说明',
|
||||
'error.conflict': '这条反馈已在别处改动,已显示最新状态',
|
||||
'error.load': '反馈状态加载失败',
|
||||
'error.generic': '反馈保存失败',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The feedback namespace key union. */
|
||||
export type MessageFeedbackKey = keyof typeof zh
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The per-message feedback controls' copy. */
|
||||
feedback: MessageFeedbackKey
|
||||
}
|
||||
}
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'action.like': 'Good response',
|
||||
'action.likeActive': 'Remove rating',
|
||||
'action.dislike': 'Bad response',
|
||||
'action.dislikeActive': 'Remove rating',
|
||||
'note.open': 'Add a note',
|
||||
'note.placeholder': 'What was good, or what went wrong? (optional)',
|
||||
'note.save': 'Save',
|
||||
'note.cancel': 'Cancel',
|
||||
'note.aria': 'Feedback note',
|
||||
'error.conflict': 'This feedback changed elsewhere; the latest state is shown',
|
||||
'error.load': 'Could not load feedback',
|
||||
'error.generic': 'Could not save feedback',
|
||||
} satisfies Record<MessageFeedbackKey, string>
|
||||
64
packages/client/ui-message-feedback/src/client/slots.ts
Normal file
64
packages/client/ui-message-feedback/src/client/slots.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* The feedback entry's injected face. The target
|
||||
* 'conversation.chat.assistant-actions' slot is declared and typed by
|
||||
* ui-conversation; this package only contributes the entry, so no SlotMap
|
||||
* merge lives here. Live per-message state arrives through the `feedback`
|
||||
* hook (the framework standard kit binds it into `useFeedback`); inject
|
||||
* carries the two mutation verbs plus the lazy loader.
|
||||
* @module @deepseek-ai/dsh-client-ui-message-feedback/client/slots
|
||||
*/
|
||||
|
||||
import type {
|
||||
HostObservable, InjectFace, PropsLocale, PropsRuntime,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types'
|
||||
// Type-only: pulls this package's LocaleNamespaceMap merge (the 'feedback' seat).
|
||||
import type {} from './locales.ts'
|
||||
import type { MessageFeedbackActionResult, MessageFeedbackView } from './controller.ts'
|
||||
|
||||
/** Injected business face of one assistant-message feedback entry. */
|
||||
export interface MessageFeedbackInjected {
|
||||
hooks: {
|
||||
/** The owning Session's feedback view, shared by every message control. */
|
||||
feedback: HostObservable<MessageFeedbackView>
|
||||
}
|
||||
/** Load the Session's feedback once, on first interaction. */
|
||||
ensure: () => Promise<MessageFeedbackActionResult>
|
||||
/**
|
||||
* Create or replace this Session's feedback for one message.
|
||||
* @param messageId - target assistant message.
|
||||
* @param rating - desired judgment.
|
||||
* @param note - optional explanation.
|
||||
*/
|
||||
rate: (
|
||||
messageId: MessageId,
|
||||
rating: MessageFeedbackRating,
|
||||
note?: string,
|
||||
) => Promise<MessageFeedbackActionResult>
|
||||
/**
|
||||
* Apply the requested judgment, retracting instead when the committed rating
|
||||
* already matches. The controller decides from the committed item, so a click
|
||||
* before the first list read still toggles the stored value.
|
||||
* @param messageId - target assistant message.
|
||||
* @param rating - the judgment the human asked for.
|
||||
*/
|
||||
toggle: (messageId: MessageId, rating: MessageFeedbackRating) => Promise<MessageFeedbackActionResult>
|
||||
/**
|
||||
* Drop the note while keeping the rating.
|
||||
* @param messageId - target assistant message.
|
||||
*/
|
||||
clearNote: (messageId: MessageId) => Promise<MessageFeedbackActionResult>
|
||||
/**
|
||||
* Remove this Session's feedback for one message.
|
||||
* @param messageId - target assistant message.
|
||||
*/
|
||||
clear: (messageId: MessageId) => Promise<MessageFeedbackActionResult>
|
||||
}
|
||||
|
||||
/** Full props of one assistant-message feedback entry. */
|
||||
export type MessageFeedbackActionProps =
|
||||
PropsRuntime<'conversation.chat.assistant-actions'>
|
||||
& InjectFace<MessageFeedbackInjected>
|
||||
& PropsLocale<'feedback'>
|
||||
6
packages/client/ui-message-feedback/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-message-feedback/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-message-feedback/src/index.ts
Normal file
9
packages/client/ui-message-feedback/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Message feedback surface plugin, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dsh.client declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this surface plugin. */
|
||||
export function apply(): void {}
|
||||
33
packages/client/ui-message-feedback/src/invariant.ts
Normal file
33
packages/client/ui-message-feedback/src/invariant.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-message-feedback`.
|
||||
* @module @deepseek-ai/dsh-client-ui-message-feedback/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-message-feedback'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-feedback-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the plugin owns one slot registration and one
|
||||
* per-session controller map, both released by the same effect disposer. The
|
||||
* lifecycle spec proves the registration is withdrawn and every controller is
|
||||
* dropped when the owning fiber is disposed, so no second authority exists to
|
||||
* check at runtime.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user