Merge branch 'master' into feat/web-plugin-settings-tabs

This commit is contained in:
Ziya
2026-08-12 18:40:38 +08:00
committed by GitHub
124 changed files with 3272 additions and 222 deletions

View File

@@ -66,8 +66,9 @@
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
@@ -81,8 +82,9 @@
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",

View File

@@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import goalsRemote from '@deepseek-ai/dsh-goal/remote'
import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/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'
@@ -11,6 +12,7 @@ export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inven
export type {} from '@deepseek-ai/dsh-commands/remote'
export type {} from '@deepseek-ai/dsh-goal/remote'
export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote'
export type {} from '@deepseek-ai/dsh-message-feedback/remote'
// The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypeRTRemoteEvent` is `never` and every `$on` call fails.
export type { ApiRemoteForwardedEvent } from '../types.ts'
@@ -57,7 +59,7 @@ export const inject = ['remote']
export async function apply(ctx: Context): Promise<() => Promise<void>> {
const disposers: Array<() => Promise<void>> = []
try {
for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote]) {
for (const contribution of [commandsRemote, goalsRemote, pluginInventoryRemote, messageFeedbackRemote]) {
disposers.push(await ctx.remote.$mount(contribution))
}
} catch (error) {

View File

@@ -26,6 +26,9 @@
{
"path": "../../goal/goal"
},
{
"path": "../../feedback/message-feedback"
},
{
"path": "../../host/plugin-inventory"
},

View File

@@ -120,14 +120,12 @@
- id: session-projection
name: '@deepseek-ai/dsh-session-projection'
# Session telemetry, on for every dsh mode: mirrors every session-log
# event (assistant/chunk projected to first-of-step) plus ops markers onto
# OTLP/HTTP log records, streaming on the batch processor's cadence
# (10s/batch here) — not at exit; a crash loses at most the last unexported
# interval. No telemetry/record redaction rule is mounted yet, so exports
# are the raw captured copy; the deployment stance, env seams, and
# follow-ups are pinned in the web-telemetry-default-mount Agent Note.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
# Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE
# explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors
# session-log records onto OTLP/HTTP logs with no telemetry/record redaction
# rule, so exports are the raw captured copy. The deployment stance, env
# seams, and follow-ups are pinned in the default-off Agent Note.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty
# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
# process out (the launchers patch the row disabled; config cannot disable
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.userid,
@@ -144,6 +142,7 @@
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED'
shutdownTimeoutMillis: 3000
exporter:
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'

View File

@@ -27,11 +27,14 @@ describe('dsh-base bundle', () => {
)
expect(Array.isArray(parsed)).toBe(true)
// The base layer is one insert list over the empty profile root.
const rows = (parsed as { insert?: { id?: string }[] }[]).flatMap(
const rows = (parsed as { insert?: { id?: string; config?: Record<string, unknown> }[] }[]).flatMap(
patch => patch.insert ?? [],
)
expect(rows.length).toBeGreaterThan(50)
expect(rows.some(row => row.id === 'agent-loop')).toBe(true)
expect(rows.find(row => row.id === 'telemetry-otel')?.config?.['mode']).toEqual({
__jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
})
expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(1)
expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(1)
expect(manifest.dependencies).toMatchObject({

View File

@@ -222,6 +222,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'

View File

@@ -58,6 +58,7 @@
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "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:^",

View File

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

View File

@@ -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. */}

View File

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

View File

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

View File

@@ -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,

View File

@@ -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,

View 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: 0187fee61863292ee4e51975efe414a4e41ba066
README.zh.md: 9d225f4632e15a2b54437cbe30d376acb509a156

View 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. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets.
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`.

View 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`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。
每个 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`

View File

@@ -0,0 +1,86 @@
{
"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.2",
"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-connection": "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/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-connection": "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:^",
"@deepseek-ai/dsh-type-meta": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts"
]
}

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

View 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 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.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): FeedbackInjected => {
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),
}
},
}, FeedbackActions)
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 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.load': 'Could not load feedback',
'error.generic': 'Could not save feedback',
} satisfies Record<FeedbackKey, 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-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>
/**
* 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<FeedbackActionResult>
/**
* Drop the note while keeping the rating.
* @param messageId - target assistant message.
*/
clearNote: (messageId: MessageId) => 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'>

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

View File

@@ -0,0 +1,214 @@
// @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 }[] = []
// The generated face wraps every business result in the carrier envelope.
const carried = <T,>(value: T) => Promise.resolve({ ok: true as const, value })
const messageFeedback = {
list: (request: unknown) => {
calls.push({ method: 'list', request })
return carried({ ok: true as const, value: { items: [seeded] } })
},
put: (request: unknown) => {
calls.push({ method: 'put', request })
return carried({ ok: true as const, value: seeded })
},
delete: (request: unknown) => {
calls.push({ method: 'delete', request })
return carried({ 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('routes toggle and clearNote to the controller', async () => {
const b = await bench()
await b.fiber.await()
const face = b.entry()!.inject!(sid('s1'))
expect(await face.toggle(MSG, 'negative')).toEqual({ ok: true })
expect(await face.clearNote(MSG)).toEqual({ ok: true })
// The seeded item is positive with no note, so a negative toggle replaces it
// through put, and clearNote has nothing to drop and touches no wire.
const puts = b.calls.filter(call => call.method === 'put').map(call => call.request)
expect(puts).toHaveLength(1)
expect(puts[0]).toMatchObject({ messageId: MSG, rating: 'negative' })
})
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()
})
})

View File

@@ -0,0 +1,674 @@
/**
* 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. */
type Script = {
list?: (request: unknown) => Promise<unknown>
put?: (request: unknown) => Promise<unknown>
delete?: (request: unknown) => Promise<unknown>
}
/**
* A recording fake Remote. Scripts return the *business* result; this wraps it
* in the carrier envelope the generated face uses, so specs stay readable. A
* script may also return an already-enveloped `{ok:false,error:{code,message,
* details}}` to exercise a carrier failure.
*/
function fakeRemote(script: Script = {}) {
const calls: { method: string; request: unknown }[] = []
const isCarrier = (v: unknown): boolean =>
typeof v === 'object' && v !== null && 'ok' in v && v.ok === false
&& 'error' in v && 'details' in ((v as { error: object }).error ?? {})
const record = (method: 'list' | 'put' | 'delete', real: Script[keyof Script], fallback: unknown) =>
(request: never): Promise<never> => {
calls.push({ method, request })
const business = real === undefined ? Promise.resolve(fallback) : real(request)
return business.then(v => (isCarrier(v) ? v : { ok: true, value: v })) as Promise<never>
}
const remote = {
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 } }),
} as unknown as MessageFeedbackRemote
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()
})
it('renders a human explanation for every business failure code', async () => {
const codes = [
['session-not-found', 'this session is no longer persisted'],
['target-not-found', 'this message is not a persisted assistant message'],
['note-blank', 'a note must contain a non-whitespace character'],
['note-too-large', 'the note is too long'],
] as const
for (const [code, message] of codes) {
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: false, error: { code, sessionId: SESSION } } as never),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code } })
expect(controller.getSnapshot().error).toBe(message)
}
})
it('falls back to the raw code for an unrecognized failure', async () => {
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: false, error: { code: 'brand-new-code' } } as never),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toMatchObject({ ok: false, error: { code: 'brand-new-code' } })
expect(controller.getSnapshot().error).toBe('brand-new-code')
})
it('publishes nothing when the list settles after disposal', async () => {
let release = (): void => {}
const gate = new Promise<void>((resolve) => { release = resolve })
const { remote } = fakeRemote({
list: async () => {
await gate
return { ok: true, value: { items: [item()] } }
},
})
const controller = new FeedbackController(remote, SESSION)
const pending = controller.ensure()
const listener = vi.fn()
controller.subscribe(listener)
controller.dispose()
release()
expect(await pending).toEqual({ ok: true })
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
expect(listener).not.toHaveBeenCalled()
})
it('swallows a rejected list that settles after disposal', async () => {
let reject = (): void => {}
const gate = new Promise<void>((_resolve, rejectFn) => { reject = () => { rejectFn(new Error('late')) } })
const { remote } = fakeRemote({ list: () => gate })
const controller = new FeedbackController(remote, SESSION)
const pending = controller.ensure()
controller.dispose()
reject()
expect(await pending).toEqual({ ok: true })
expect(controller.getSnapshot().status).not.toBe('error')
})
it('describes a non-Error list rejection with a stable message', async () => {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
const { remote } = fakeRemote({ list: () => Promise.reject('socket string') })
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toEqual({
ok: false,
error: { code: 'transport', message: 'message feedback list failed' },
})
})
it('describes a non-Error mutation rejection with a stable message', async () => {
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test.
const { remote } = fakeRemote({ put: () => Promise.reject('nope') })
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toEqual({
ok: false,
error: { code: 'transport', message: 'message feedback mutation failed' },
})
})
it('propagates a failed load to a queued mutation without calling the wire', async () => {
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toMatchObject({
ok: false,
error: { code: 'session-not-found' },
})
expect(calls.filter(call => call.method === 'put')).toHaveLength(0)
})
it('keeps a later mutation running after an earlier one settles as a failure', async () => {
let first = true
const { remote } = fakeRemote({
put: () => {
if (first) {
first = false
return Promise.reject(new Error('first blew up'))
}
return Promise.resolve({ ok: true, value: item({ rating: 'negative' }) })
},
})
const controller = new FeedbackController(remote, SESSION)
const [a, b] = await Promise.all([
controller.rate(MSG, 'positive'),
controller.rate(MSG, 'negative'),
])
expect(a).toMatchObject({ ok: false, error: { code: 'transport' } })
expect(b).toEqual({ ok: true })
expect(controller.getSnapshot().items.get(MSG)?.rating).toBe('negative')
})
it('ignores a conflict reconciliation that lands after disposal', async () => {
// The mutate() guard only refuses work admitted after disposal, so this
// exercises commit()'s own guard: the call is already in flight when the
// fiber unloads, and its authoritative item must not be published.
let release = (): void => {}
const gate = new Promise<void>((resolve) => { release = resolve })
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } }),
put: async () => {
await gate
return { ok: false, error: { code: 'version-conflict', current: item({ version: version('v2'), rating: 'negative' }) } }
},
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
const listener = vi.fn()
controller.subscribe(listener)
const pending = controller.rate(MSG, 'negative')
controller.dispose()
release()
await pending
// publish() drops its listener set on dispose, so no subscriber is told.
expect(listener).not.toHaveBeenCalled()
})
it('drops a delete conflict reconciliation once disposed mid-flight', async () => {
let release = (): void => {}
const gate = new Promise<void>((resolve) => { release = resolve })
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
delete: async () => {
await gate
return { ok: false, error: { code: 'version-conflict', current: null } }
},
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
const pending = controller.clear(MSG)
const listener = vi.fn()
controller.subscribe(listener)
controller.dispose()
release()
await pending
// The reconciliation still computes, but no subscriber is notified.
expect(listener).not.toHaveBeenCalled()
})
it('leaves the local item untouched when a rating fails for a non-conflict reason', async () => {
const existing = item({ version: version('v3'), rating: 'positive' })
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
put: () => Promise.resolve({ ok: false, error: { code: 'note-too-large', maxBytes: 8, actualBytes: 9 } }),
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
expect(await controller.rate(MSG, 'negative', 'far too long')).toMatchObject({
ok: false,
error: { code: 'note-too-large' },
})
expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
})
it('leaves the local item untouched when a delete fails for a non-conflict reason', async () => {
const existing = item({ version: version('v4') })
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [existing] } }),
delete: () => Promise.resolve({ ok: false, error: { code: 'session-not-found', sessionId: SESSION } }),
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
expect(await controller.clear(MSG)).toMatchObject({
ok: false,
error: { code: 'session-not-found' },
})
expect(controller.getSnapshot().items.get(MSG)).toEqual(existing)
})
it('preserves a stored note when a rating switch omits one', async () => {
// Regression: a control that rendered before the first list read holds no
// item, so it passes note=undefined; that must not erase the stored note.
const stored = item({ version: version('v1'), rating: 'positive', note: 'keep me' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'negative')).toEqual({ ok: true })
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
expect(put.note).toBe('keep me')
expect(put.rating).toBe('negative')
})
it('toggle retracts when the committed rating already matches', async () => {
const stored = item({ version: version('v1'), rating: 'positive' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
})
it('toggle decides from the committed item, not a cold view', async () => {
// The click lands before any list read: the cold view knows no item, yet the
// stored rating matches, so the toggle must retract rather than re-put.
const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(controller.getSnapshot().status).toBe('cold')
expect(await controller.toggle(MSG, 'positive')).toEqual({ ok: true })
expect(calls.filter(c => c.method === 'delete')).toHaveLength(1)
})
it('toggle replaces the opposite rating and carries the note forward', async () => {
const stored = item({ version: version('v1'), rating: 'positive', note: 'kept' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.toggle(MSG, 'negative')).toEqual({ ok: true })
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
expect(put).toMatchObject({ rating: 'negative', note: 'kept', ifVersion: version('v1') })
})
it('clearNote drops the note and keeps the rating', async () => {
const stored = item({ version: version('v1'), rating: 'negative', note: 'remove me' })
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [stored] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.clearNote(MSG)).toEqual({ ok: true })
const put = calls.filter(c => c.method === 'put')[0]?.request as Record<string, unknown>
expect(put.rating).toBe('negative')
expect(put).not.toHaveProperty('note')
})
it('clearNote is a no-op when there is no note to drop', async () => {
const { remote, calls } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.clearNote(MSG)).toEqual({ ok: true })
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
})
it('resync serializes behind an in-flight mutation', async () => {
// Regression: an unserialized reconnect read could land after a newer put
// and resurrect the version that put had already replaced.
const order: string[] = []
let releasePut = (): void => {}
const putGate = new Promise<void>((r) => { releasePut = r })
const { remote } = fakeRemote({
list: () => {
order.push('list')
return Promise.resolve({ ok: true, value: { items: [item({ version: version('v1') })] } })
},
put: async () => {
order.push('put:start')
await putGate
order.push('put:end')
return { ok: true, value: item({ version: version('v9'), rating: 'negative' }) }
},
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
const rating = controller.rate(MSG, 'negative')
const resync = controller.resync()
releasePut()
await Promise.all([rating, resync])
// The reconnect read runs only after the mutation settled.
expect(order.indexOf('list', 1)).toBeGreaterThan(order.indexOf('put:end'))
})
it('refuses a mutation disposed while its seeding read is in flight', async () => {
// Dispose only once the seeding list call has actually started, so the
// mutation is already past the admission check and must be stopped by the
// second guard that runs after ensure() resolves.
let release = (): void => {}
const gate = new Promise<void>((r) => { release = r })
let started = (): void => {}
const listStarted = new Promise<void>((r) => { started = r })
const { remote, calls } = fakeRemote({
list: async () => {
started()
await gate
return { ok: true, value: { items: [] } }
},
})
const controller = new FeedbackController(remote, SESSION)
const pending = controller.rate(MSG, 'positive')
await listStarted
controller.dispose()
release()
expect(await pending).toMatchObject({ ok: false, error: { code: 'disposed' } })
expect(calls.filter(c => c.method === 'put')).toHaveLength(0)
})
it('renders a carrier failure from the Remote envelope', async () => {
// The generated face folds transport faults into ok:false with a
// RemoteFailure, so the controller reads them as values, not rejections.
const { remote } = fakeRemote({
list: () => Promise.resolve({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
}),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.ensure()).toEqual({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed' },
})
expect(controller.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
})
it('renders a carrier failure on a mutation without touching the view', async () => {
const { remote } = fakeRemote({
put: () => Promise.resolve({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
}),
})
const controller = new FeedbackController(remote, SESSION)
expect(await controller.rate(MSG, 'positive')).toEqual({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed' },
})
expect(controller.getSnapshot().items.has(MSG)).toBe(false)
})
it('renders a carrier failure on a delete', async () => {
const { remote } = fakeRemote({
list: () => Promise.resolve({ ok: true, value: { items: [item()] } }),
delete: () => Promise.resolve({
ok: false,
error: { code: 'carrier-closed', message: 'socket closed', details: {} },
}),
})
const controller = new FeedbackController(remote, SESSION)
await controller.ensure()
expect(await controller.clear(MSG)).toMatchObject({ ok: false, error: { code: 'carrier-closed' } })
expect(controller.getSnapshot().items.has(MSG)).toBe(true)
})
})

View File

@@ -0,0 +1,249 @@
// @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, MessageFeedbackRating, 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
status?: FeedbackView['status']
} = {}) {
const view: FeedbackView = {
status: options.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((_id: MessageId, _rating: MessageFeedbackRating, _note?: string) =>
Promise.resolve(options.rateResult ?? { ok: true as const }))
const clear = vi.fn((_id: MessageId) =>
Promise.resolve(options.clearResult ?? { ok: true as const }))
// The controller owns retract-vs-replace, so the double stands in for it:
// matching the shown rating retracts, anything else replaces.
const toggle = vi.fn((id: MessageId, next: MessageFeedbackRating) =>
(options.current?.rating === next ? clear(id) : rate(id, next)))
const clearNote = vi.fn((_id: MessageId) =>
Promise.resolve(options.rateResult ?? { ok: true as const }))
const useFeedback = (<T,>(select: (v: FeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = { messageId: MSG, ensure, rate, toggle, clearNote, clear, useFeedback, t } as unknown as
Parameters<typeof FeedbackActions>[0]
return { ...render(<FeedbackActions {...props} />), ensure, rate, clear, toggle, clearNote }
}
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.toggle).toHaveBeenCalledWith(MSG, 'positive') })
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.toggle).toHaveBeenCalledWith(MSG, 'negative') })
})
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.toggle).toHaveBeenCalledWith(MSG, 'positive') })
// The double routes a matching rating to clear(), mirroring the controller.
await waitFor(() => { expect(ui.clear).toHaveBeenCalledWith(MSG) })
})
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.clearNote).toHaveBeenCalledWith(MSG) })
})
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() })
})
it('keeps the editor open when the note fails to save', async () => {
const ui = mount({
current: item({ rating: 'positive' }),
rateResult: { ok: false, error: { code: 'note-too-large', message: 'too long' } },
})
fireEvent.click(ui.getByText(zh['note.open']))
fireEvent.change(ui.getByLabelText(zh['note.aria']), { target: { value: 'x'.repeat(20) } })
fireEvent.click(ui.getByText(zh['note.save']))
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
// The draft survives so the human can shorten it instead of retyping.
expect(ui.getByLabelText(zh['note.aria'])).toBeTruthy()
})
it('publishes no state after the row unmounts mid-flight', async () => {
let release = (): void => {}
const gate = new Promise<FeedbackActionResult>((resolve) => {
release = () => { resolve({ ok: false, error: { code: 'target-not-found', message: 'gone' } }) }
})
const view: FeedbackView = { status: 'ready', items: new Map(), error: null }
const useFeedback = (<T,>(select: (v: FeedbackView) => T): T =>
useSyncExternalStore(() => () => {}, () => select(view))) as never
const props = {
messageId: MSG,
ensure: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
rate: vi.fn(() => gate),
toggle: vi.fn(() => gate),
clearNote: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
clear: vi.fn(() => Promise.resolve<FeedbackActionResult>({ ok: true })),
useFeedback,
t,
} as unknown as Parameters<typeof FeedbackActions>[0]
const ui = render(<FeedbackActions {...props} />)
const errors: unknown[] = []
const onError = (event: ErrorEvent): void => { errors.push(event.error) }
window.addEventListener('error', onError)
fireEvent.click(ui.getByLabelText(zh['action.like']))
ui.unmount()
release()
await gate
window.removeEventListener('error', onError)
expect(errors).toEqual([])
})
it('surfaces a failed list load next to the controls', async () => {
const ui = mount({ status: 'error' })
expect(ui.getByText(zh['error.load'])).toBeTruthy()
})
it('prefers the action failure over the load notice', async () => {
const ui = mount({
status: 'error',
rateResult: { ok: false, error: { code: 'target-not-found', message: 'gone' } },
})
fireEvent.click(ui.getByLabelText(zh['action.like']))
await waitFor(() => { expect(ui.getByText(zh['error.generic'])).toBeTruthy() })
expect(ui.queryByText(zh['error.load'])).toBeNull()
})
})

View 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": "../../typert/type-meta"
},
{
"path": "../locale"
},
{
"path": "../runtime"
},
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
}
]
}

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

View File

@@ -2,5 +2,5 @@
# 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-settings-general/README.md
README.md: 0cdffdfaad20784535a7ed010ad4b71d63a0a2c1
README.zh.md: 0b72b3db96b335f0c288758b3f7a8b56441ee88c
README.md: d8578a7fbd451c1b7ec54dadeb3d391d597cc18e
README.zh.md: 246c04193e79f46f1e8035c6a40f55a20f1d0c26

View File

@@ -8,7 +8,7 @@ The shell ships no copy of its own — all text arrives from registrants. Nav la
A loopback browser loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, loopback-only `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Remote browsers never register the action and never issue the privileged settings read.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while the forwarded `settings/document-updated` event makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice identifies `DSH_TELEMETRY_DISABLED=1` as the telemetry opt-out.
`src/onboarding-copy.ts` is the single editable owner of the complete notice plus `WELCOME_NOTICE_VERSION`; both supported GUI locales intentionally render the same Chinese copy. The Host half registers `ui-onboarding` in the user-settings seam. A loopback browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A non-loopback browser cannot access the privileged settings API: it still presents the notice, but Continue advances only the current browser process and a reload presents the notice again. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request. The notice states that session telemetry is disabled by default, names the `FEEDBACK_ONLY` and `FULL` opt-in modes, and discloses that `FULL` also enables dsh-sdk command telemetry.
## Model Experience

View File

@@ -8,7 +8,7 @@
回环浏览器通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染**打开配置文件**。该操作发送无路径参数且仅限回环访问的 `settings.openDocument` 请求Host 会再次解析提供方路径、在文档缺失时将其创建出来并交给原生文本编辑器macOS 上使用 `open -t`绕过浏览器文件关联Linux 和 Windows 上使用桌面文件关联WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。远程浏览器从不注册该操作,也从不发起这项特权设置读取。
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;转发的 `settings/document-updated` 事件则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API它仍会显示通知但「继续」只推进当前浏览器进程重新加载后会再次显示通知。版本不同时系统也会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知明确以 `DSH_TELEMETRY_DISABLED=1` 作为遥测关闭方式
`src/onboarding-copy.ts` 是完整通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源GUI 支持的两种 locale 都有意渲染同一份中文文案。宿主端在用户设置 seam 中注册 `ui-onboarding`。回环浏览器会比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。非回环浏览器不能访问受保护的设置 API它仍会显示通知但「继续」只推进当前浏览器进程重新加载后会再次显示通知。版本不同时系统也会有意重新显示通知。欢迎页保留原文的每个段落仅强调最后一段中指定的句段初始焦点落在标题上并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。通知说明会话遥测默认禁用,列出 `FEEDBACK_ONLY``FULL` 两种显式启用模式,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测
## 模型体验

View File

@@ -8,7 +8,7 @@ export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion'
* Bump only when the notice changes materially and every user should see it
* again. The acknowledgement is compared for exact equality.
*/
export const WELCOME_NOTICE_VERSION = '2026-07-30.7'
export const WELCOME_NOTICE_VERSION = '2026-08-11.1'
/** The complete editable welcome notice in both supported GUI locales. */
export const WELCOME_NOTICE_COPY = {
@@ -17,7 +17,7 @@ export const WELCOME_NOTICE_COPY = {
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
'内测版本默认会上传 Session Log如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY如需持续上传可以设置 DSH_TELEMETRY_MODE=FULL但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
continueLabel: '继续',
@@ -27,7 +27,7 @@ export const WELCOME_NOTICE_COPY = {
paragraphs: [
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭,可以设置环境变量 DSH_TELEMETRY_DISABLED=1。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
'内测版本默认会上传 Session Log如需在提交反馈时共享会话日志,可以设置环境变量 DSH_TELEMETRY_MODE=FEEDBACK_ONLY如需持续上传可以设置 DSH_TELEMETRY_MODE=FULL但该模式同时会启用 dsh-sdk 命令遥测,上报匿名 ID、命令结果以及脱敏后的项目配置。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。',
],
feedbackEmphasis: '如果您有任何反馈与建议,请在企业微信群中留言告诉我们',
continueLabel: '继续',

View File

@@ -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,

View File

@@ -2,5 +2,5 @@
# 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/session/session-telemetry-otel/README.md
README.md: e3eae475a180419c7822d51858ae156052a663d6
README.zh.md: cfdf36ac5783850cc5e63bbb2b622584f1064b0c
README.md: 230efa3c8c77f1fea407cc02e493abea405169c9
README.zh.md: 79ef1131ad69ec2d10b9fe8ae3ea812f2b723c8e

View File

@@ -10,7 +10,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED
mode: FULL # explicit opt-in; default: DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
@@ -21,9 +21,9 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th
| `mode` | Behavior |
|---|---|
| `FULL` | Default. Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. |
| `FULL` | Each projected record, including lifecycle ops records, is handed to the OTel SDK immediately. |
| `FEEDBACK_ONLY` | Each `feedback/record` replays, projects, and redacts the canonical session-log suffix through that event. Later records wait for another feedback event and remain local if none arrives. |
| `DISABLED` | No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. |
| `DISABLED` | Default. No coordinator, provider, processor, or exporter is constructed. No telemetry record leaves the process. A `feedback/record` logs `session telemetry is DISABLED; nothing will be shared and this feedback remains local`; the event remains in the local session log. |
Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`TelemetryMode.FULL`, `TelemetryMode.FEEDBACK_ONLY`, or `TelemetryMode.DISABLED`); raw string literals are not assignable. Serialized Cordis configuration continues to use the string values shown above.

View File

@@ -10,7 +10,7 @@
- id: telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: FULL # FULL (default), FEEDBACK_ONLY, or DISABLED
mode: FULL # explicit opt-in; default: DISABLED
shutdownTimeoutMillis: 3000 # optional; defaults to 3000
exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter
url: https://collector.example.com/v1/logs
@@ -21,9 +21,9 @@
| `mode` | 行为 |
|---|---|
| `FULL` | 默认值。每条已投影记录都立即交给 OTel SDK包括生命周期运维记录。 |
| `FULL` | 每条已投影记录都立即交给 OTel SDK包括生命周期运维记录。 |
| `FEEDBACK_ONLY` | 每个 `feedback/record` 都会回放权威会话日志中截至该事件的后缀,并进行投影与脱敏。后续记录等待下一个反馈事件;如果没有后续反馈,则留在本地。 |
| `DISABLED` | 不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 |
| `DISABLED` | 默认值。不构造协调器、提供方、处理器或导出器。没有遥测记录会离开进程。`feedback/record` 会记录 `session telemetry is DISABLED; nothing will be shared and this feedback remains local`;该事件留在本地会话日志中。 |
程序化 TypeScript 配置使用导出的 `TelemetryMode` 枚举(`TelemetryMode.FULL``TelemetryMode.FEEDBACK_ONLY``TelemetryMode.DISABLED`);原始字符串字面量不可赋值。序列化后的 Cordis 配置继续使用上表所示的字符串值。

View File

@@ -48,7 +48,7 @@ export enum TelemetryMode {
}
/** Default session-sharing policy for schema and direct construction. */
export const DEFAULT_TELEMETRY_MODE = TelemetryMode.FULL
export const DEFAULT_TELEMETRY_MODE = TelemetryMode.DISABLED
const DISABLED_FEEDBACK_WARNING = 'session telemetry is DISABLED; nothing will be shared and this feedback remains local'
const NON_CANONICAL_FEEDBACK_WARNING = 'session telemetry ignored a feedback event absent from the canonical session log'
@@ -89,7 +89,7 @@ function sharingStatusFor(mode: TelemetryMode): TelemetrySharingStatus {
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Sharing policy; defaults to immediate `FULL` delivery. */
/** Sharing policy; defaults to local-only `DISABLED` behavior. */
mode?: TelemetryMode
/**
* Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete

View File

@@ -99,6 +99,7 @@ async function boot(url: string) {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
exporter: { url, headers: { authorization: 'Bearer test-token' } },
})
return { ctx, fiber }
@@ -180,6 +181,7 @@ describe('TelemetryOtel wire', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
exporter: { url },
processor: { scheduledDelayMillis: 10 },
})
@@ -210,6 +212,7 @@ describe('TelemetryOtel wire', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
exporter: { url, timeoutMillis: 60_000 },
processor: { scheduledDelayMillis: 10, exportTimeoutMillis: 60_000 },
shutdownTimeoutMillis: 50,
@@ -238,6 +241,7 @@ describe('TelemetryOtel wire', () => {
// verbatim passthrough must hand it (and every other field) to the
// exporter rather than silently rebuilding url/headers only.
const fiber = await ctx.plugin(TelemetryOtel, {
mode: TelemetryMode.FULL,
exporter: { url, compression: 'gzip' },
} as Config)
const session = ctx.sessions.create(SessionId('gzip'), { meta: {} })
@@ -369,7 +373,7 @@ describe('TelemetryOtel wire', () => {
const fullCtx = new Context()
await fullCtx.plugin(SessionStore)
const full = await fullCtx.plugin(TelemetryOtel, { exporter: { url } })
const full = await fullCtx.plugin(TelemetryOtel, { mode: TelemetryMode.FULL, exporter: { url } })
expect(fullCtx.telemetry.sharing).toBe('full')
await full.dispose()
@@ -385,20 +389,35 @@ describe('TelemetryOtel wire', () => {
expect(disabledCtx.telemetry.sharing).toBe('disabled')
await disabled.dispose()
// An omitted mode is DISABLED, so the default also shares nothing.
const defaultCtx = new Context()
await defaultCtx.plugin(SessionStore)
const defaulted = await defaultCtx.plugin(TelemetryOtel, {})
expect(defaultCtx.telemetry.sharing).toBe('disabled')
await defaulted.dispose()
// No record was emitted by any mode, so nothing reached the collector.
expect(captures).toEqual([])
})
it('defaults direct construction to full delivery', async () => {
it('defaults direct construction to disabled delivery', async () => {
const { url, captures } = await mockCollector()
const ctx = new Context()
await ctx.plugin(SessionStore)
new TelemetryOtel(ctx, { exporter: { url } })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
new TelemetryOtel(ctx, {
exporter: { url },
processor: { maxExportBatchSize: 0 },
})
const session = ctx.sessions.create(SessionId('direct-default'), { meta: {} })
session.append('turn/start', { turn: 1 })
recordFeedback(session, 'local report')
await ctx.fiber.dispose()
expect(eventTypes(captures)).toContain('turn/start')
expect(warn).toHaveBeenCalledWith(
'session telemetry is DISABLED; nothing will be shared and this feedback remains local',
)
expect(captures).toEqual([])
})
})
@@ -407,23 +426,23 @@ describe('TelemetryOtel config fails loud', () => {
expectTypeOf<Config['mode']>().toEqualTypeOf<TelemetryMode | undefined>()
expectTypeOf<'FULL'>().not.toExtend<TelemetryMode>()
expectTypeOf<TelemetryMode.FULL>().toExtend<TelemetryMode>()
expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.FULL)
expect(DEFAULT_TELEMETRY_MODE).toBe(TelemetryMode.DISABLED)
expect(Config({}).mode).toBe(DEFAULT_TELEMETRY_MODE)
})
it.each([
[{}, /exporter\.url is required/],
[{ exporter: { url: '' } }, /exporter\.url is required/],
[{ exporter: { url: 'not a url' } }, /not a valid URL/],
[{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
[{ mode: TelemetryMode.FULL }, /exporter\.url is required/],
[{ mode: TelemetryMode.FULL, exporter: { url: '' } }, /exporter\.url is required/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'not a url' } }, /not a valid URL/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'ftp://collector' } }, /must be http\(s\)/],
[{ mode: TelemetryMode.FEEDBACK_ONLY }, /exporter\.url is required/],
[{ mode: 'INVALID' }, /INVALID/],
// The SDK accepts a non-positive batch size but its shutdown drain then
// splices empty batches forever — dispose would hang, so reject at load.
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
[{ exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
[{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
[{ exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0 } }, /maxExportBatchSize/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, processor: { maxExportBatchSize: 0.5 } }, /maxExportBatchSize/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: 0 }, /shutdownTimeoutMillis/],
[{ mode: TelemetryMode.FULL, exporter: { url: 'http://c/v1/logs' }, shutdownTimeoutMillis: Number.POSITIVE_INFINITY }, /shutdownTimeoutMillis/],
])('rejects %j at plugin load', async (config, message) => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -488,7 +507,7 @@ describe('dsh-session-telemetry-otel real-load-path guard', () => {
const unwrapped = loader.unwrapExports(module) as Parameters<Context['plugin']>[0]
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(unwrapped, { exporter: { url } })
const fiber = await ctx.plugin(unwrapped, { mode: TelemetryMode.FULL, exporter: { url } })
expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel)
await fiber.dispose()
})