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:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -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;
}

View File

@@ -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>}
</>
)
}

View 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)
}
}
}
}

View 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()
}
})
}

View 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>

View 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'>

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View 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 {}

View 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 */