feat(feedback): add the Web surface for message feedback
Consume the durable message-feedback sidecar from #2217 in the browser: per-message Like/Dislike with an optional note, contributed through a declared assistant-actions slot. - carry MessageId on finalized AssistantMessageNode so a target is nameable - declare conversation.chat.assistant-actions and render it in the IconActions row between copy and branch - hold one FeedbackController per Session with per-item ifVersion CAS, reconciling a version-conflict from the reply's authoritative item - mount messageFeedbackRemote alongside goalsRemote
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;
|
||||
}
|
||||
140
packages/client/ui-feedback/src/client/FeedbackActions.tsx
Normal file
140
packages/client/ui-feedback/src/client/FeedbackActions.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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-feedback/client/FeedbackActions
|
||||
*/
|
||||
|
||||
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 { FeedbackActionProps } from './slots.ts'
|
||||
import css from './FeedbackActions.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 FeedbackActions({ messageId, ensure, rate, clear, useFeedback, t }: FeedbackActionProps) {
|
||||
const item = useFeedback(view => view.items.get(messageId))
|
||||
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)
|
||||
// Re-clicking the active rating retracts it; the note goes with it.
|
||||
if (rating === next) {
|
||||
setNoteOpen(false)
|
||||
void clear(messageId).then(settle)
|
||||
return
|
||||
}
|
||||
void rate(messageId, next, item?.note).then(settle)
|
||||
}, [clear, item?.note, messageId, rate, rating, settle])
|
||||
|
||||
const onSaveNote = useCallback(() => {
|
||||
if (rating === undefined) return
|
||||
const trimmed = draft.trim()
|
||||
setPending(true)
|
||||
setFailure(null)
|
||||
void rate(messageId, rating, trimmed.length === 0 ? undefined : trimmed).then((result) => {
|
||||
settle(result)
|
||||
if (result.ok && alive.current) setNoteOpen(false)
|
||||
})
|
||||
}, [draft, messageId, rate, rating, 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>
|
||||
)}
|
||||
{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}>
|
||||
{t('note.save')}
|
||||
</button>
|
||||
<button type="button" className={css.noteCancel} onClick={() => { setNoteOpen(false) }}>
|
||||
{t('note.cancel')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{failure !== null && <span className={css.failure} role="status">{failure}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
267
packages/client/ui-feedback/src/client/controller.ts
Normal file
267
packages/client/ui-feedback/src/client/controller.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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 { 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, named without the transport. */
|
||||
export interface MessageFeedbackRemote {
|
||||
list: (request: { sessionId: SessionId }) => Promise<MessageFeedbackListResult>
|
||||
put: (request: {
|
||||
sessionId: SessionId
|
||||
messageId: MessageId
|
||||
rating: MessageFeedbackRating
|
||||
note?: string
|
||||
ifVersion: MessageFeedbackItem['version'] | null
|
||||
}) => Promise<MessageFeedbackPutResult>
|
||||
delete: (request: {
|
||||
sessionId: SessionId
|
||||
messageId: MessageId
|
||||
ifVersion: MessageFeedbackItem['version']
|
||||
}) => Promise<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 } }
|
||||
|
||||
const EMPTY_ITEMS: ReadonlyMap<MessageId, MessageFeedbackItem> = Object.freeze(new Map())
|
||||
|
||||
const INITIAL_VIEW: FeedbackView = Object.freeze({
|
||||
status: 'cold',
|
||||
items: EMPTY_ITEMS,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const OK: FeedbackActionResult = Object.freeze({ ok: true })
|
||||
|
||||
/** 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) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or replace feedback for one message, comparing against the version
|
||||
* this controller last observed.
|
||||
* @param messageId - target assistant message.
|
||||
* @param rating - desired judgment.
|
||||
* @param note - optional explanation; omitted leaves the note unset.
|
||||
* @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)
|
||||
const result = await this.remote.put({
|
||||
sessionId: this.sessionId,
|
||||
messageId,
|
||||
rating,
|
||||
...(note === undefined ? {} : { note }),
|
||||
ifVersion: observed?.version ?? null,
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
const result = await this.remote.delete({
|
||||
sessionId: this.sessionId,
|
||||
messageId,
|
||||
ifVersion: observed.version,
|
||||
})
|
||||
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 result = await this.remote.list({ sessionId: this.sessionId })
|
||||
if (this.disposed) return OK
|
||||
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: Object.freeze(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>): Promise<FeedbackActionResult> {
|
||||
const guarded = async (): Promise<FeedbackActionResult> => {
|
||||
if (this.disposed) return { ok: false, error: { code: 'disposed', message: 'feedback controller is disposed' } }
|
||||
const loaded = await this.ensure()
|
||||
if (!loaded.ok) return loaded
|
||||
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)
|
||||
// Every queued operation settles carrier and business failures as a
|
||||
// FeedbackActionResult, so this controlled tail cannot reject.
|
||||
this.operationTail = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Replace one message's entry, keeping every other entry's identity. */
|
||||
private commit(messageId: MessageId, item: MessageFeedbackItem | null): void {
|
||||
if (this.disposed) return
|
||||
const items = new Map(this.view.items)
|
||||
if (item === null) items.delete(messageId)
|
||||
else items.set(messageId, item)
|
||||
this.publish({ status: 'ready', items: Object.freeze(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
packages/client/ui-feedback/src/client/index.ts
Normal file
84
packages/client/ui-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 FeedbackController 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-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 { FeedbackController } from './controller.ts'
|
||||
import { FeedbackActions } from './FeedbackActions.tsx'
|
||||
import type { FeedbackInjected } from './slots.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
export { FeedbackActions } from './FeedbackActions.tsx'
|
||||
export { FeedbackController } from './controller.ts'
|
||||
export type {
|
||||
FeedbackActionResult, FeedbackStatus, FeedbackView, MessageFeedbackRemote,
|
||||
} from './controller.ts'
|
||||
export type { FeedbackActionProps, FeedbackInjected } from './slots.ts'
|
||||
export type { FeedbackKey } 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-feedback: dictionaries')
|
||||
|
||||
const controllers = new Map<SessionId, FeedbackController>()
|
||||
const controllerFor = (sessionId: SessionId): FeedbackController => {
|
||||
let controller = controllers.get(sessionId)
|
||||
if (controller === undefined) {
|
||||
controller = new FeedbackController(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.refresh()
|
||||
}
|
||||
})
|
||||
|
||||
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): FeedbackInjected => {
|
||||
const controller = controllerFor(sessionId)
|
||||
return {
|
||||
hooks: { feedback: controller },
|
||||
ensure: () => controller.ensure(),
|
||||
rate: (messageId, rating, note) => controller.rate(messageId, rating, note),
|
||||
clear: messageId => controller.clear(messageId),
|
||||
}
|
||||
},
|
||||
}, FeedbackActions)
|
||||
return () => {
|
||||
dispose()
|
||||
for (const controller of controllers.values()) controller.dispose()
|
||||
controllers.clear()
|
||||
}
|
||||
})
|
||||
}
|
||||
41
packages/client/ui-feedback/src/client/locales.ts
Normal file
41
packages/client/ui-feedback/src/client/locales.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** `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.generic': '反馈保存失败',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The feedback namespace key union. */
|
||||
export type FeedbackKey = keyof typeof zh
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The per-message feedback controls' copy. */
|
||||
feedback: FeedbackKey
|
||||
}
|
||||
}
|
||||
|
||||
/** 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.generic': 'Could not save feedback',
|
||||
} satisfies Record<FeedbackKey, string>
|
||||
51
packages/client/ui-feedback/src/client/slots.ts
Normal file
51
packages/client/ui-feedback/src/client/slots.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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-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 { FeedbackActionResult, FeedbackView } from './controller.ts'
|
||||
|
||||
/** Injected business face of one assistant-message feedback entry. */
|
||||
export interface FeedbackInjected {
|
||||
hooks: {
|
||||
/** The owning Session's feedback view, shared by every message control. */
|
||||
feedback: HostObservable<FeedbackView>
|
||||
}
|
||||
/** Load the Session's feedback once, on first interaction. */
|
||||
ensure: () => Promise<FeedbackActionResult>
|
||||
/**
|
||||
* 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<FeedbackActionResult>
|
||||
/**
|
||||
* Remove this Session's feedback for one message.
|
||||
* @param messageId - target assistant message.
|
||||
*/
|
||||
clear: (messageId: MessageId) => Promise<FeedbackActionResult>
|
||||
}
|
||||
|
||||
/** Full props of one assistant-message feedback entry. */
|
||||
export type FeedbackActionProps =
|
||||
PropsRuntime<'conversation.chat.assistant-actions'>
|
||||
& InjectFace<FeedbackInjected>
|
||||
& PropsLocale<'feedback'>
|
||||
6
packages/client/ui-feedback/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-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-feedback/src/index.ts
Normal file
9
packages/client/ui-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-feedback/src/invariant.ts
Normal file
33
packages/client/ui-feedback/src/invariant.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-feedback`.
|
||||
* @module @deepseek-ai/dsh-client-ui-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-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