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:
@@ -56,6 +56,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
@@ -65,6 +66,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^",
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
|
||||
import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
|
||||
import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
|
||||
export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta'
|
||||
export type {} from '@deepseek-ai/dsh-goal/remote'
|
||||
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -23,5 +25,11 @@ export const inject = ['remote']
|
||||
* @returns disposer after every selected Remote namespace is ready.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
return await ctx.remote.$mount(goalsRemote)
|
||||
const mounted = [
|
||||
await ctx.remote.$mount(goalsRemote),
|
||||
await ctx.remote.$mount(messageFeedbackRemote),
|
||||
]
|
||||
return async () => {
|
||||
for (const dispose of mounted.reverse()) await dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
{
|
||||
"path": "../../goal/goal"
|
||||
},
|
||||
{
|
||||
"path": "../../feedback/message-feedback"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
}
|
||||
|
||||
@@ -215,6 +215,11 @@
|
||||
- id: ui-goal
|
||||
name: '@deepseek-ai/dsh-client-ui-goal'
|
||||
|
||||
# Per-message feedback: Like/Dislike plus an optional note in the
|
||||
# assistant-message action strip, over the messageFeedback Remote.
|
||||
- id: ui-feedback
|
||||
name: '@deepseek-ai/dsh-client-ui-feedback'
|
||||
|
||||
# Model selection: the /model popupSelect + composer seat over session.models.
|
||||
- id: ui-model
|
||||
name: '@deepseek-ai/dsh-client-ui-model'
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-feedback": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-model": "workspace:^",
|
||||
|
||||
@@ -96,6 +96,12 @@ export interface AssistantTiming {
|
||||
export interface AssistantMessageNode {
|
||||
kind: 'assistant'
|
||||
seq: number
|
||||
/**
|
||||
* Stable identity of the finalized model output, carried from the
|
||||
* `assistant/message` event. Absent on interruption-frozen partials: those
|
||||
* were never finalized, so they address no durable message.
|
||||
*/
|
||||
messageId?: MessageId
|
||||
/** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */
|
||||
time: number
|
||||
turn: number
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy
|
||||
// live, optional branch wiring, and an optional date-aware clock.
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -29,6 +29,11 @@ export interface MessageIconActionsProps {
|
||||
branchUnavailable?: boolean | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
/**
|
||||
* Slot-rendered actions owned by independent plugins, placed between the
|
||||
* built-in copy and branch controls.
|
||||
*/
|
||||
extraActions?: ReactNode
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -39,7 +44,8 @@ export interface MessageIconActionsProps {
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t,
|
||||
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className,
|
||||
extraActions, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const reasonId = useId()
|
||||
@@ -109,6 +115,7 @@ export function MessageIconActions({
|
||||
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{extraActions}
|
||||
{onBranch !== undefined && (
|
||||
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
|
||||
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
|
||||
|
||||
@@ -5,11 +5,12 @@ import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { assistantText } from './turn-assistant.ts'
|
||||
import css from './TurnTailNodeView.module.css'
|
||||
|
||||
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'>
|
||||
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
|
||||
& PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'>
|
||||
|
||||
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
|
||||
export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
node, openFile, forkAt, renderSlotChain, t, useSession,
|
||||
node, openFile, forkAt, renderSlot, renderSlotChain, t, useSession,
|
||||
}: TurnTailNodeViewProps) {
|
||||
const data = node.data
|
||||
const hasLaterChatNode = useSession(snapshot =>
|
||||
@@ -25,6 +26,12 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
const runMs = turn.start === undefined || turn.end === undefined
|
||||
? undefined
|
||||
: Math.max(0, turn.end.time - turn.start.time)
|
||||
// Interruption-frozen partials carry no messageId, so they address no
|
||||
// durable message and contribute no per-message actions.
|
||||
const messageId = closing.finalNode.messageId
|
||||
const assistantActions = messageId === undefined
|
||||
? null
|
||||
: renderSlot('conversation.chat.assistant-actions', { messageId })
|
||||
return (
|
||||
<div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
|
||||
{tail}
|
||||
@@ -38,6 +45,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
onBranch={() => { forkAt(closing.finalNode.seq) }}
|
||||
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
|
||||
className={css.actions}
|
||||
extraActions={assistantActions}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,10 @@ export function registerChatNodeRenderers(ctx: Context): void {
|
||||
name: 'conversation.chat.node',
|
||||
key: 'turn-tail',
|
||||
locale: NS,
|
||||
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
||||
children: {
|
||||
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
|
||||
'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
}, TurnTailNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView))
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
TurnLocation, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerBlock } from '../input/blocks.ts'
|
||||
import type {
|
||||
@@ -77,6 +78,18 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* only to return null; an all-declined chain renders nothing.
|
||||
*/
|
||||
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
|
||||
/**
|
||||
* Action strip attached to one finalized assistant message, rendered
|
||||
* inside that message's IconActions row. The chat entry owns the render
|
||||
* site and passes the addressed message identity; contributors add
|
||||
* per-message actions without importing the conversation implementation.
|
||||
* Entries render by ascending `order`.
|
||||
*/
|
||||
'conversation.chat.assistant-actions': {
|
||||
kind: 'list'
|
||||
scope: 'session'
|
||||
owner: AssistantActionOwnerProps
|
||||
}
|
||||
/** Selected Tool call output inside the details panel. */
|
||||
'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
|
||||
/**
|
||||
@@ -253,6 +266,16 @@ export interface TurnTailOwnerProps {
|
||||
openFile: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner currency of the assistant-message action strip: the durable identity
|
||||
* of the one finalized message the contributed actions address. Only finalized
|
||||
* messages reach this slot, so the id is always present.
|
||||
*/
|
||||
export interface AssistantActionOwnerProps {
|
||||
/** Stable identity carried from the `assistant/message` event. */
|
||||
messageId: MessageId
|
||||
}
|
||||
|
||||
/** Hook constrained to business data published on the current Chat Node's Turn. */
|
||||
export type UseChatNodeTurnData = <Key extends Extract<keyof ConversationTurnDataMap, string>>(
|
||||
key: Key,
|
||||
|
||||
@@ -152,6 +152,7 @@ function finalNode(
|
||||
return {
|
||||
kind: 'assistant',
|
||||
seq: event.seq,
|
||||
messageId: event.data.message.id,
|
||||
time: event.time,
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
|
||||
6
packages/client/ui-feedback/README.i18n.yaml
Normal file
6
packages/client/ui-feedback/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-feedback/README.md
|
||||
README.md: 2f347a22427ce61ea1093434273ba9b4ba759852
|
||||
README.zh.md: 2aea4cd2f4c29312f2c771094b7831171bff760f
|
||||
25
packages/client/ui-feedback/README.md
Normal file
25
packages/client/ui-feedback/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @deepseek-ai/dsh-client-ui-feedback
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls.
|
||||
|
||||
One `FeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript. The read is deferred to the first hover or focus rather than fired on mount, because the controls mount once per settled message in the visible history.
|
||||
|
||||
Mutations go through `ctx.remote.messageFeedback`; the Host owns per-item compare-and-set. Every `put` and `delete` 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 Session. Mutations serialize per Session, so a queued operation always compares against the committed version. Re-clicking the recorded rating retracts the feedback; switching sides carries the existing note forward.
|
||||
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), the `FeedbackActions` component, the `FeedbackController` class, and the injected face types.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as feedback is a sidecar that never enters the append-only Session log, the model context, or telemetry; no rating or note is ever visible to the model.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; no feedback mutation touches the history tail.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Note size is a Host policy** — the deployment configures `maxNoteBytes` (8192 in the Web bundle) and the Host rejects an oversized note with `note-too-large`. The editor does not pre-check the limit, so an oversized note fails on save rather than while typing.
|
||||
- **No cross-tab push** — a second tab's rating becomes visible on reconnect or on the next conflict reply, not immediately; the sidecar publishes no live frames.
|
||||
- **Chat view only** — the trajectory and waterfall views render no feedback controls even though their assistant nodes now carry the same `messageId`.
|
||||
25
packages/client/ui-feedback/README.zh.md
Normal file
25
packages/client/ui-feedback/README.zh.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# @deepseek-ai/dsh-client-ui-feedback
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。
|
||||
|
||||
每个 Session 一个 `FeedbackController`,支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话。该读取延迟到首次 hover 或 focus 才发起,而不是在挂载时触发,因为可见历史中每条已结束的消息都会挂载一次控件。
|
||||
|
||||
变更通过 `ctx.remote.messageFeedback` 提交,按条目的 compare-and-set 由 Host 负责。每次 `put` 和 `delete` 都携带本 controller 最后观察到的 `version`;`version-conflict` 响应会带回权威条目,因此竞争失败时直接用该响应对账,无需重新拉取整个 Session。变更按 Session 串行,排队中的操作总是与已提交的版本比较。再次点击已记录的评分会撤回反馈;切换到另一侧会保留已有备注。
|
||||
|
||||
`/client` 导出插件本体(`apply`/`inject`)、`FeedbackActions` 组件、`FeedbackController` 类以及注入面类型。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。反馈是 sidecar,不进入 append-only 的 Session 日志、模型上下文或遥测;任何评分与备注对模型都不可见。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;任何反馈变更都不触碰历史尾部。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **备注大小是 Host 策略** —— 部署方配置 `maxNoteBytes`(Web bundle 中为 8192),超长备注由 Host 以 `note-too-large` 拒绝。编辑器不预先校验该上限,因此超长备注在保存时才失败,而不是在输入过程中。
|
||||
- **无跨标签页推送** —— 另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现;该 sidecar 不发布实时帧。
|
||||
- **仅限对话视图** —— trajectory 与 waterfall 视图不渲染反馈控件,尽管它们的助手节点现在也带有相同的 `messageId`。
|
||||
82
packages/client/ui-feedback/package.json
Normal file
82
packages/client/ui-feedback/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-feedback",
|
||||
"description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote",
|
||||
"version": "0.0.1-rc.1",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-feedback"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-message-feedback": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -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 */
|
||||
197
packages/client/ui-feedback/tests/browser-plugin.spec.tsx
Normal file
197
packages/client/ui-feedback/tests/browser-plugin.spec.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* ui-feedback browser half on a real cordis Context with fake slots/remote
|
||||
* faces: the plugin registers the feedback entry at
|
||||
* conversation.chat.assistant-actions, one controller per Session backs every
|
||||
* message in that Session, a reconnect refreshes only Sessions that were
|
||||
* already read, and registration plus controller disposal ride the plugin
|
||||
* fiber (HMR safety). The node half and the invariant companion are exercised
|
||||
* over the same Context.
|
||||
*/
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'
|
||||
import type { FeedbackInjected } from '../src/client/slots.ts'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const sid = (k: string): SessionId => k as SessionId
|
||||
const MSG = 'm-1' as MessageId
|
||||
|
||||
const seeded: MessageFeedbackItem = {
|
||||
messageId: MSG,
|
||||
rating: 'positive',
|
||||
version: 'v1' as MessageFeedbackVersion,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}
|
||||
|
||||
/** Boot the plugin over fake faces; the Remote namespace records every call. */
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const calls: { method: string; request: unknown }[] = []
|
||||
const messageFeedback = {
|
||||
list: (request: unknown) => {
|
||||
calls.push({ method: 'list', request })
|
||||
return Promise.resolve({ ok: true as const, value: { items: [seeded] } })
|
||||
},
|
||||
put: (request: unknown) => {
|
||||
calls.push({ method: 'put', request })
|
||||
return Promise.resolve({ ok: true as const, value: seeded })
|
||||
},
|
||||
delete: (request: unknown) => {
|
||||
calls.push({ method: 'delete', request })
|
||||
return Promise.resolve({ ok: true as const, value: { absent: true as const } })
|
||||
},
|
||||
}
|
||||
class RemoteService extends Service {
|
||||
constructor(serviceCtx: Context) {
|
||||
super(serviceCtx, 'remote')
|
||||
}
|
||||
}
|
||||
new RemoteService(ctx)
|
||||
ctx.provide('remote.messageFeedback', messageFeedback)
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' } },
|
||||
} as never, (() => null) as never)
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
return {
|
||||
ctx,
|
||||
fiber,
|
||||
calls,
|
||||
entry: () => {
|
||||
const entry = ctx.slots.entries('conversation.chat.assistant-actions')[0]
|
||||
if (entry === undefined) return undefined
|
||||
return {
|
||||
...entry.options,
|
||||
locale: entry.locale,
|
||||
inject: entry.inject as unknown as ((sessionId: SessionId) => FeedbackInjected) | undefined,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-feedback browser plugin', () => {
|
||||
it('registers the feedback entry with the documented id, order, and locale', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
expect(b.entry()).toMatchObject({ id: 'feedback', order: 10, locale: 'feedback' })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('exposes the feedback hook plus the ensure/rate/clear verbs', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
const face = b.entry()!.inject!(sid('s1'))
|
||||
expect(face.hooks.feedback.getSnapshot()).toMatchObject({ status: 'cold' })
|
||||
expect(face.ensure).toBeTypeOf('function')
|
||||
expect(face.rate).toBeTypeOf('function')
|
||||
expect(face.clear).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('shares one controller across every message in the same Session', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
const first = b.entry()!.inject!(sid('s1'))
|
||||
const second = b.entry()!.inject!(sid('s1'))
|
||||
expect(first.hooks.feedback).toBe(second.hooks.feedback)
|
||||
|
||||
await first.ensure()
|
||||
await second.ensure()
|
||||
expect(b.calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps separate Sessions on separate controllers', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
const one = b.entry()!.inject!(sid('s1'))
|
||||
const two = b.entry()!.inject!(sid('s2'))
|
||||
expect(one.hooks.feedback).not.toBe(two.hooks.feedback)
|
||||
|
||||
await one.ensure()
|
||||
await two.ensure()
|
||||
expect(b.calls.filter(call => call.method === 'list').map(call => call.request)).toEqual([
|
||||
{ sessionId: 's1' },
|
||||
{ sessionId: 's2' },
|
||||
])
|
||||
})
|
||||
|
||||
it('routes rate and clear to the Remote with the addressed message', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
const face = b.entry()!.inject!(sid('s1'))
|
||||
expect(await face.rate(MSG, 'negative', 'wrong answer')).toEqual({ ok: true })
|
||||
expect(await face.clear(MSG)).toEqual({ ok: true })
|
||||
|
||||
expect(b.calls.filter(call => call.method === 'put')[0]?.request).toMatchObject({
|
||||
sessionId: 's1', messageId: MSG, rating: 'negative', note: 'wrong answer',
|
||||
})
|
||||
expect(b.calls.filter(call => call.method === 'delete')[0]?.request).toMatchObject({
|
||||
sessionId: 's1', messageId: MSG,
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes only Sessions already read when the connection resets', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
|
||||
const warm = b.entry()!.inject!(sid('warm'))
|
||||
await warm.ensure()
|
||||
b.entry()!.inject!(sid('cold'))
|
||||
const before = b.calls.filter(call => call.method === 'list').length
|
||||
|
||||
b.ctx.emit('connection/reset')
|
||||
await Promise.resolve()
|
||||
|
||||
const reads = b.calls.filter(call => call.method === 'list')
|
||||
expect(reads).toHaveLength(before + 1)
|
||||
expect(reads.at(-1)?.request).toEqual({ sessionId: 'warm' })
|
||||
})
|
||||
|
||||
it('withdraws the registration and disposes controllers with the plugin fiber', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const face = b.entry()!.inject!(sid('s1'))
|
||||
await face.ensure()
|
||||
|
||||
await b.fiber.dispose()
|
||||
|
||||
expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(0)
|
||||
// A disposed controller refuses further mutations, so no request outlives the fiber.
|
||||
const before = b.calls.length
|
||||
expect(await face.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
|
||||
expect(b.calls).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('re-registers cleanly when the plugin is reloaded', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
await b.fiber.dispose()
|
||||
|
||||
const reloaded = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await reloaded.await()
|
||||
|
||||
expect(b.ctx.slots.entries('conversation.chat.assistant-actions')).toHaveLength(1)
|
||||
expect(b.entry()).toMatchObject({ id: 'feedback' })
|
||||
})
|
||||
|
||||
it('the node half applies without host-side behavior', () => {
|
||||
// The invariant companion is mounted by the vitest-wide invariant host on
|
||||
// every Context this suite creates; its registration is covered there.
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
})
|
||||
})
|
||||
273
packages/client/ui-feedback/tests/controller.spec.ts
Normal file
273
packages/client/ui-feedback/tests/controller.spec.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* FeedbackController: the browser-local object layer over one Session's
|
||||
* message-feedback sidecar. These specs pin the per-item compare-and-set
|
||||
* contract — every mutation sends the version last observed, a conflict
|
||||
* reconciles from the authoritative item carried by the reply, mutations
|
||||
* serialize per Session, and a disposed controller stops publishing.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { MessageId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {
|
||||
MessageFeedbackItem, MessageFeedbackVersion,
|
||||
} from '@deepseek-ai/dsh-message-feedback/types'
|
||||
import { FeedbackController, type MessageFeedbackRemote } from '../src/client/controller.ts'
|
||||
|
||||
const SESSION = 's-1' as SessionId
|
||||
const MSG = 'm-1' as MessageId
|
||||
const OTHER = 'm-2' as MessageId
|
||||
|
||||
const version = (v: string): MessageFeedbackVersion => v as MessageFeedbackVersion
|
||||
|
||||
function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem {
|
||||
return {
|
||||
messageId: MSG,
|
||||
rating: 'positive',
|
||||
version: version('v1'),
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A recording fake Remote whose per-method answers are scripted per call. */
|
||||
function fakeRemote(script: Partial<MessageFeedbackRemote> = {}) {
|
||||
const calls: { method: string; request: unknown }[] = []
|
||||
const record = <K extends keyof MessageFeedbackRemote>(
|
||||
method: K,
|
||||
real: MessageFeedbackRemote[K] | undefined,
|
||||
fallback: Awaited<ReturnType<MessageFeedbackRemote[K]>>,
|
||||
): MessageFeedbackRemote[K] =>
|
||||
((request: Parameters<MessageFeedbackRemote[K]>[0]) => {
|
||||
calls.push({ method, request })
|
||||
return real === undefined
|
||||
? Promise.resolve(fallback)
|
||||
: (real as (input: typeof request) => ReturnType<MessageFeedbackRemote[K]>)(request)
|
||||
}) as MessageFeedbackRemote[K]
|
||||
const remote: MessageFeedbackRemote = {
|
||||
list: record('list', script.list, { ok: true, value: { items: [] } }),
|
||||
put: record('put', script.put, { ok: true, value: item() }),
|
||||
delete: record('delete', script.delete, { ok: true, value: { absent: true } }),
|
||||
}
|
||||
return { remote, calls }
|
||||
}
|
||||
|
||||
describe('FeedbackController', () => {
|
||||
it('seeds the view from one list read and keys items by message id', async () => {
|
||||
const seeded = item({ note: 'good' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [seeded] } }),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(controller.getSnapshot().status).toBe('cold')
|
||||
expect(await controller.ensure()).toEqual({ ok: true })
|
||||
|
||||
const view = controller.getSnapshot()
|
||||
expect(view.status).toBe('ready')
|
||||
expect(view.items.get(MSG)).toEqual(seeded)
|
||||
expect(calls).toEqual([{ method: 'list', request: { sessionId: SESSION } }])
|
||||
})
|
||||
|
||||
it('collapses concurrent loads onto one in-flight read', async () => {
|
||||
const { remote, calls } = fakeRemote()
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
await Promise.all([controller.ensure(), controller.ensure(), controller.refresh()])
|
||||
|
||||
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sends ifVersion null for a first rating and the observed version afterwards', async () => {
|
||||
const first = item({ version: version('v1') })
|
||||
const second = item({ version: version('v2'), rating: 'negative' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
put: request => Promise.resolve({
|
||||
ok: true,
|
||||
value: (request as { rating: string }).rating === 'positive' ? first : second,
|
||||
}),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toEqual({ ok: true })
|
||||
expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
|
||||
|
||||
const puts = calls.filter(call => call.method === 'put').map(call => call.request)
|
||||
expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'positive', ifVersion: null })
|
||||
expect(puts[1]).toMatchObject({ messageId: MSG, rating: 'negative', ifVersion: version('v1') })
|
||||
expect(controller.getSnapshot().items.get(MSG)).toEqual(second)
|
||||
})
|
||||
|
||||
it('forwards an optional note and omits the field when absent', async () => {
|
||||
const { remote, calls } = fakeRemote()
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
await controller.rate(MSG, 'positive', 'helpful')
|
||||
await controller.rate(OTHER, 'negative')
|
||||
|
||||
const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
|
||||
expect(puts[0]?.note).toBe('helpful')
|
||||
expect(puts[1]).not.toHaveProperty('note')
|
||||
})
|
||||
|
||||
it('reconciles a version conflict from the authoritative item without refetching', async () => {
|
||||
const authoritative = item({ version: version('v9'), rating: 'negative', note: 'changed elsewhere' })
|
||||
const { remote, calls } = fakeRemote({
|
||||
put: () => Promise.resolve({
|
||||
ok: false,
|
||||
error: { code: 'version-conflict', current: authoritative },
|
||||
}),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'version-conflict', message: 'feedback changed elsewhere' },
|
||||
})
|
||||
|
||||
expect(controller.getSnapshot().items.get(MSG)).toEqual(authoritative)
|
||||
expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('drops the local item when a conflict reports the feedback is gone', async () => {
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
|
||||
delete: () => Promise.resolve({
|
||||
ok: false,
|
||||
error: { code: 'version-conflict', current: null },
|
||||
}),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
|
||||
expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'version-conflict' } })
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
||||
})
|
||||
|
||||
it('deletes with the observed version and removes the item on success', async () => {
|
||||
const { remote, calls } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v7') })] } }),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
|
||||
expect(await controller.clear(MSG)).toEqual({ ok: true })
|
||||
|
||||
expect(calls.filter(call => call.method === 'delete')[0]?.request)
|
||||
.toEqual({ sessionId: SESSION, messageId: MSG, ifVersion: version('v7') })
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats clearing an unrated message as already satisfied without a call', async () => {
|
||||
const { remote, calls } = fakeRemote()
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.clear(MSG)).toEqual({ ok: true })
|
||||
expect(calls.filter(call => call.method === 'delete')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('serializes mutations so each one compares against the committed version', async () => {
|
||||
let inFlight = 0
|
||||
let overlapped = false
|
||||
const versions = [version('v1'), version('v2')]
|
||||
let index = 0
|
||||
const { remote, calls } = fakeRemote({
|
||||
put: async () => {
|
||||
inFlight += 1
|
||||
if (inFlight > 1) overlapped = true
|
||||
await Promise.resolve()
|
||||
inFlight -= 1
|
||||
const next = versions[index] ?? version('vN')
|
||||
index += 1
|
||||
return { ok: true, value: item({ version: next }) }
|
||||
},
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
await Promise.all([controller.rate(MSG, 'positive'), controller.rate(MSG, 'negative')])
|
||||
|
||||
expect(overlapped).toBe(false)
|
||||
const puts = calls.filter(call => call.method === 'put').map(call => call.request as Record<string, unknown>)
|
||||
expect(puts[0]?.ifVersion).toBeNull()
|
||||
expect(puts[1]?.ifVersion).toBe(version('v1'))
|
||||
})
|
||||
|
||||
it('publishes an error status when the list read is rejected by the Host', async () => {
|
||||
const { remote } = fakeRemote({
|
||||
list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
|
||||
})
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'this session is no longer persisted',
|
||||
})
|
||||
})
|
||||
|
||||
it('settles a transport throw as a result instead of rejecting', async () => {
|
||||
const { remote } = fakeRemote({ list: () => Promise.reject(new Error('socket closed')) })
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.ensure()).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'transport', message: 'socket closed' },
|
||||
})
|
||||
expect(controller.getSnapshot().status).toBe('error')
|
||||
})
|
||||
|
||||
it('settles a mutation transport throw without corrupting the view', async () => {
|
||||
const { remote } = fakeRemote({ put: () => Promise.reject(new Error('socket closed')) })
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toEqual({
|
||||
ok: false,
|
||||
error: { code: 'transport', message: 'socket closed' },
|
||||
})
|
||||
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
|
||||
})
|
||||
|
||||
it('notifies subscribers on publication and stops after unsubscribe', async () => {
|
||||
const { remote } = fakeRemote()
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = controller.subscribe(listener)
|
||||
|
||||
await controller.ensure()
|
||||
const seen = listener.mock.calls.length
|
||||
expect(seen).toBeGreaterThan(0)
|
||||
|
||||
unsubscribe()
|
||||
await controller.rate(MSG, 'positive')
|
||||
expect(listener).toHaveBeenCalledTimes(seen)
|
||||
})
|
||||
|
||||
it('contains a throwing subscriber at the observable boundary', async () => {
|
||||
const { remote } = fakeRemote()
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
controller.subscribe(() => { throw new Error('subscriber exploded') })
|
||||
const healthy = vi.fn()
|
||||
controller.subscribe(healthy)
|
||||
|
||||
await controller.ensure()
|
||||
|
||||
expect(healthy).toHaveBeenCalled()
|
||||
expect(spy).toHaveBeenCalled()
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('refuses mutations and stops publishing once disposed', async () => {
|
||||
const { remote, calls } = fakeRemote()
|
||||
const controller = new FeedbackController(remote, SESSION)
|
||||
await controller.ensure()
|
||||
const listener = vi.fn()
|
||||
controller.subscribe(listener)
|
||||
|
||||
controller.dispose()
|
||||
const before = calls.length
|
||||
|
||||
expect(await controller.rate(MSG, 'positive')).toMatchObject({ ok: false, error: { code: 'disposed' } })
|
||||
expect(calls).toHaveLength(before)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
172
packages/client/ui-feedback/tests/feedback-actions.spec.tsx
Normal file
172
packages/client/ui-feedback/tests/feedback-actions.spec.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* FeedbackActions rendering and gestures: the rating buttons reflect the
|
||||
* shared view, re-clicking the active rating retracts it, the note editor
|
||||
* saves through the same rate verb, the Session's feedback is read on first
|
||||
* interaction rather than on mount, and a rejected mutation surfaces inline
|
||||
* without losing the authoritative state.
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { MessageFeedbackItem, MessageFeedbackVersion } from '@deepseek-ai/dsh-message-feedback/types'
|
||||
import { FeedbackActions } from '../src/client/FeedbackActions.tsx'
|
||||
import type { FeedbackActionResult, FeedbackView } from '../src/client/controller.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const MSG = 'm-1' as MessageId
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
function item(overrides: Partial<MessageFeedbackItem> = {}): MessageFeedbackItem {
|
||||
return {
|
||||
messageId: MSG,
|
||||
rating: 'positive',
|
||||
version: 'v1' as MessageFeedbackVersion,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the controls over a fixed view and recording verbs. */
|
||||
function mount(options: {
|
||||
current?: MessageFeedbackItem | undefined
|
||||
rateResult?: FeedbackActionResult
|
||||
clearResult?: FeedbackActionResult
|
||||
} = {}) {
|
||||
const view: FeedbackView = {
|
||||
status: 'ready',
|
||||
items: new Map(options.current === undefined ? [] : [[MSG, options.current]]),
|
||||
error: null,
|
||||
}
|
||||
const ensure = vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true }))
|
||||
const rate = vi.fn(() => Promise.resolve(options.rateResult ?? { ok: true as const }))
|
||||
const clear = vi.fn(() => Promise.resolve(options.clearResult ?? { ok: true as const }))
|
||||
const useFeedback = (<T,>(select: (v: FeedbackView) => T): T =>
|
||||
useSyncExternalStore(() => () => {}, () => select(view))) as never
|
||||
const props = { messageId: MSG, ensure, rate, clear, useFeedback, t } as unknown as
|
||||
Parameters<typeof FeedbackActions>[0]
|
||||
return { ...render(<FeedbackActions {...props} />), ensure, rate, clear }
|
||||
}
|
||||
|
||||
describe('FeedbackActions', () => {
|
||||
it('renders both rating buttons unpressed with no recorded feedback', () => {
|
||||
const ui = mount()
|
||||
|
||||
expect(ui.getByLabelText(zh['action.like']).getAttribute('aria-pressed')).toBe('false')
|
||||
expect(ui.getByLabelText(zh['action.dislike']).getAttribute('aria-pressed')).toBe('false')
|
||||
})
|
||||
|
||||
it('marks the recorded rating pressed and offers to retract it', () => {
|
||||
const ui = mount({ current: item({ rating: 'negative' }) })
|
||||
|
||||
expect(ui.getByLabelText(zh['action.dislikeActive']).getAttribute('aria-pressed')).toBe('true')
|
||||
expect(ui.getByLabelText(zh['action.like']).getAttribute('aria-pressed')).toBe('false')
|
||||
})
|
||||
|
||||
it('reads the Session feedback on first interaction, once', () => {
|
||||
const ui = mount()
|
||||
const like = ui.getByLabelText(zh['action.like'])
|
||||
|
||||
fireEvent.pointerEnter(like)
|
||||
fireEvent.pointerEnter(like)
|
||||
fireEvent.focus(ui.getByLabelText(zh['action.dislike']))
|
||||
|
||||
expect(ui.ensure).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not read the Session feedback on mount', () => {
|
||||
const ui = mount()
|
||||
|
||||
expect(ui.ensure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rates a message that has no feedback yet', async () => {
|
||||
const ui = mount()
|
||||
|
||||
fireEvent.click(ui.getByLabelText(zh['action.like']))
|
||||
|
||||
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', undefined) })
|
||||
expect(ui.clear).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces the opposite rating and carries the existing note forward', async () => {
|
||||
const ui = mount({ current: item({ rating: 'positive', note: 'keep me' }) })
|
||||
|
||||
fireEvent.click(ui.getByLabelText(zh['action.dislike']))
|
||||
|
||||
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'negative', 'keep me') })
|
||||
})
|
||||
|
||||
it('retracts the feedback when the active rating is clicked again', async () => {
|
||||
const ui = mount({ current: item({ rating: 'positive' }) })
|
||||
|
||||
fireEvent.click(ui.getByLabelText(zh['action.likeActive']))
|
||||
|
||||
await waitFor(() => { expect(ui.clear).toHaveBeenCalledWith(MSG) })
|
||||
expect(ui.rate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves a typed note through the rate verb and closes the editor', async () => {
|
||||
const ui = mount({ current: item({ rating: 'positive' }) })
|
||||
|
||||
fireEvent.click(ui.getByText(zh['note.open']))
|
||||
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: ' precise and short ' } })
|
||||
fireEvent.click(ui.getByText(zh['note.save']))
|
||||
|
||||
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', 'precise and short') })
|
||||
await waitFor(() => { expect(ui.queryByLabelText(zh['note.aria'])).toBeNull() })
|
||||
})
|
||||
|
||||
it('clears the note when the editor is emptied', async () => {
|
||||
const ui = mount({ current: item({ rating: 'positive', note: 'old note' }) })
|
||||
|
||||
fireEvent.click(ui.getByText('old note'))
|
||||
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: ' ' } })
|
||||
fireEvent.click(ui.getByText(zh['note.save']))
|
||||
|
||||
await waitFor(() => { expect(ui.rate).toHaveBeenCalledWith(MSG, 'positive', undefined) })
|
||||
})
|
||||
|
||||
it('seeds the editor with the recorded note and abandons it on cancel', () => {
|
||||
const ui = mount({ current: item({ rating: 'positive', note: 'old note' }) })
|
||||
|
||||
fireEvent.click(ui.getByText('old note'))
|
||||
expect((ui.getByLabelText(zh['note.aria']) as HTMLTextAreaElement).value).toBe('old note')
|
||||
|
||||
fireEvent.click(ui.getByText(zh['note.cancel']))
|
||||
expect(ui.queryByLabelText(zh['note.aria'])).toBeNull()
|
||||
expect(ui.rate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('offers no note editor before a rating is recorded', () => {
|
||||
const ui = mount()
|
||||
|
||||
expect(ui.queryByText(zh['note.open'])).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a lost race with the conflict copy', async () => {
|
||||
const ui = mount({
|
||||
rateResult: { ok: false, error: { code: 'version-conflict', message: 'feedback changed elsewhere' } },
|
||||
})
|
||||
|
||||
fireEvent.click(ui.getByLabelText(zh['action.like']))
|
||||
|
||||
await waitFor(() => { expect(ui.getByText(zh['error.conflict'])).toBeTruthy() })
|
||||
})
|
||||
|
||||
it('reports any other failure with the generic copy', async () => {
|
||||
const ui = mount({
|
||||
rateResult: { ok: false, error: { code: 'target-not-found', message: 'no such message' } },
|
||||
})
|
||||
|
||||
fireEvent.click(ui.getByLabelText(zh['action.like']))
|
||||
|
||||
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
|
||||
})
|
||||
})
|
||||
42
packages/client/ui-feedback/tsconfig.json
Normal file
42
packages/client/ui-feedback/tsconfig.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../feedback/message-feedback"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-feedback/tsdown.config.ts
Normal file
3
packages/client/ui-feedback/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-feedback', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
@@ -208,6 +208,7 @@ function finalNode(
|
||||
return {
|
||||
kind: 'assistant',
|
||||
seq: event.seq,
|
||||
messageId: event.data.message.id,
|
||||
time: event.time,
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
|
||||
Reference in New Issue
Block a user