From f2f9700de2a8a9ad115e048fd7dae128c1fc19ae Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 8 Sep 2026 15:34:29 +0800 Subject: [PATCH] feat(feedback): open one feedback dialog from /feedback and Dislike, with categories and a toast Dislike and a bare /feedback open the Session's feedback dialog, which collects one of seven fixed categories and an optional description and records through the messageFeedback put or the new sessionFeedback Remote; Like records at once. A recorded rating shows the filled glyph and every submission is acknowledged by a toast. feedback/record and MessageFeedbackItem carry the optional category; the note popover, its layout scenario, and the clearNote/clear verbs are removed. ui-commands gains the action command kind the /feedback decoration runs as. Closes #3515 --- ...8-feedback-dialog-and-categories.i18n.yaml | 6 + ...26-09-08-feedback-dialog-and-categories.md | 33 + ...09-08-feedback-dialog-and-categories.zh.md | 33 + apps/web/tests/feedback-release.e2e.ts | 19 +- apps/web/tests/message-feedback-layout.e2e.ts | 336 --------- apps/web/tests/message-feedback.e2e.ts | 46 +- apps/web/tsconfig.json | 1 - docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 + docs/capability-seams.zh.md | 4 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 30 +- docs/module-graph.zh.md | 30 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 8 +- docs/persistence-catalog.zh.md | 4 +- docs/subsystems/feedback.i18n.yaml | 4 +- docs/subsystems/feedback.md | 102 ++- docs/subsystems/feedback.zh.md | 102 ++- packages/api/remotes/package.json | 3 +- packages/api/remotes/src/client/index.ts | 4 +- packages/api/remotes/tsconfig.client.json | 3 + packages/bundle/web-app/cordis.patch.yml | 5 +- packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 4 +- packages/client/ui-commands/README.zh.md | 4 +- .../client/ui-commands/src/client/contract.ts | 24 +- .../client/ui-commands/src/client/index.ts | 3 +- .../client/ui-commands/src/client/service.ts | 43 +- .../ui-commands/tests/service.client.spec.ts | 41 +- .../ui-message-feedback/README.i18n.yaml | 4 +- packages/client/ui-message-feedback/README.md | 25 +- .../client/ui-message-feedback/README.zh.md | 27 +- .../client/ui-message-feedback/package.json | 10 +- .../src/client/FeedbackDialog.module.css | 93 +++ .../src/client/FeedbackDialog.tsx | 110 +++ .../client/MessageFeedbackActions.module.css | 102 --- .../src/client/MessageFeedbackActions.tsx | 301 ++------ .../src/client/controller.ts | 91 +-- .../ui-message-feedback/src/client/dialog.ts | 129 ++++ .../ui-message-feedback/src/client/index.ts | 123 ++-- .../ui-message-feedback/src/client/locales.ts | 40 +- .../ui-message-feedback/src/client/slots.ts | 68 +- .../ui-message-feedback/src/client/surface.ts | 44 ++ .../tests/browser-plugin.client.spec.tsx | 173 ++++- .../tests/controller.client.spec.ts | 129 +--- .../tests/dialog.client.spec.ts | 182 +++++ .../tests/feedback-dialog.client.spec.tsx | 122 ++++ .../message-feedback-actions.client.spec.tsx | 689 +++--------------- .../tests/styles.client.spec.ts | 70 +- .../client/ui-message-feedback/tsconfig.json | 9 + .../tests/browser-plugin.client.spec.ts | 21 +- .../tests/browser-plugin.client.spec.ts | 26 +- .../use-anchored-position.client.spec.tsx | 5 +- .../src/client/slot-catalog.ts | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 37 +- packages/feedback/README.i18n.yaml | 4 +- packages/feedback/README.md | 4 +- packages/feedback/README.zh.md | 4 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 46 +- .../feedback/command-feedback/README.zh.md | 46 +- .../feedback/command-feedback/package.json | 30 +- .../feedback/command-feedback/src/index.ts | 83 ++- .../feedback/command-feedback/src/types.ts | 83 +++ .../tests/command-feedback.spec.ts | 77 +- .../feedback/command-feedback/tsconfig.json | 3 + .../message-feedback/README.i18n.yaml | 4 +- packages/feedback/message-feedback/README.md | 10 +- .../feedback/message-feedback/README.zh.md | 10 +- .../feedback/message-feedback/package.json | 6 +- .../feedback/message-feedback/src/index.ts | 6 +- .../feedback/message-feedback/src/types.ts | 5 + .../tests/message-feedback.spec.ts | 56 ++ .../feedback/message-feedback/tsconfig.json | 3 + .../tests/feedback-composition.spec.ts | 2 +- .../tests/egress.spec.ts | 2 +- .../tests/fixtures/driver.ts | 2 +- .../session-telemetry-otel/tests/otel.spec.ts | 32 +- pnpm-lock.yaml | 22 + scripts/gen-cordis-catalog.ts | 3 + scripts/gen-doc-graphs.ts | 7 + scripts/smoke-python-runtime.py | 2 +- .../advanced/session.v2.jsonl | 3 +- scripts/type-equiv.manifest.json | 30 + snapshots/sdk/sdk.snapshot.ts | 2 +- snapshots/sdk/text-turn/feedback-producer.mjs | 6 +- snapshots/sdk/text-turn/session.v2.jsonl | 5 +- .../feedback-release.expected.json | 5 +- .../geometry.expected.md | 10 - .../web/message-feedback-layout/snapshot.yml | 9 - tsconfig.base.json | 1 + tsconfig.host.json | 1 - 102 files changed, 2321 insertions(+), 1874 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md create mode 100644 .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md delete mode 100644 apps/web/tests/message-feedback-layout.e2e.ts create mode 100644 packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css create mode 100644 packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx create mode 100644 packages/client/ui-message-feedback/src/client/dialog.ts create mode 100644 packages/client/ui-message-feedback/src/client/surface.ts create mode 100644 packages/client/ui-message-feedback/tests/dialog.client.spec.ts create mode 100644 packages/client/ui-message-feedback/tests/feedback-dialog.client.spec.tsx create mode 100644 packages/feedback/command-feedback/src/types.ts delete mode 100644 snapshots/web/message-feedback-layout/geometry.expected.md delete mode 100644 snapshots/web/message-feedback-layout/snapshot.yml diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml new file mode 100644 index 0000000000..b391ceb4ac --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md +2026-09-08-feedback-dialog-and-categories.md: dbd417e8df4f1cb2b957f4beb612375b05aab08b +2026-09-08-feedback-dialog-and-categories.zh.md: 7fe29c905f410c84234ea757b7b4bab9f674249d diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md new file mode 100644 index 0000000000..dbd417e8df --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md @@ -0,0 +1,33 @@ +# Agent Note: Feedback dialog, categories, and the acknowledgement toast + +Status: implemented + +English | [中文](2026-09-08-feedback-dialog-and-categories.zh.md) + +## Problem + +The Web client had two disconnected feedback paths with no visible outcome. `/feedback ` recorded a Session remark and rendered an acknowledgement row in the transcript; the Like/Dislike pair recorded a rating at once, with a note popover anchored under the row for free text. Neither path told the user what was submitted or where it went, neither collected a category, and a Dislike, the case in which a user is most willing to explain, asked nothing. Issue #3515 and the design doc for it ask for one dialog reachable from the composer menu, from a bare `/feedback`, and from Dislike, with seven fixed categories, an optional description, a success toast, and a filled glyph for a recorded rating, while Like keeps recording at once. + +## Decision + +`command-feedback` owns the category taxonomy as the `FeedbackCategory` union and the `FEEDBACK_CATEGORIES` tuple in its client-safe `./types` export, and `feedback/record` becomes `{ text?, category? }`: blank text is recorded as absent, and an entry with neither member still records, because the log delivery that the feedback authorizes is the content. The same package publishes the `sessionFeedback.record` Remote through `TypertRemoteService`, resolving the live Session by id and calling the existing `recordFeedback` producer, so the dialog records the same event as the command without command bookkeeping. `message-feedback` adds the optional `category` to `MessageFeedbackItem` and `MessageFeedbackPutRequest`, validates stored values against the tuple, and counts a category change as a material edit. + +`ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts a negative judgment with the dialog's category and note through the message controller, the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind this PR adds to `CommandUiSpec`, a bare invocation that consumes the trigger token and runs a client callback without submitting anything. Dislike opens the same dialog for the message. Like calls `toggle`, which now reports the rating it committed, so the row acknowledges a recorded Like and stays silent on a retraction. The note popover, `clearNote`, and `clear` are removed: the dialog is the only note editor, a rating switch stores the bare judgment, and clicking a recorded rating retracts it. + +The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with the code. + +## Alternatives considered + +**Encode the category into the note text.** A prefix in free text is not filterable without parsing and would leak into the verbatim note that telemetry uploads; a durable id in the payload is what a consumer can group by. + +**Submit the dialog through the command plane as `/feedback `.** The command rejects empty text, cannot carry a category, and writes an acknowledgement row the design replaces with a toast; the Remote records the same event with neither constraint. + +**Keep the note popover beside the dialog.** Two editors for one note with different reachability would leave the row two-line at some widths, the defect the popover was introduced to avoid, and the design shows only the thumbs. + +**A Toast per message control.** The composer overlay already mounts once per Session, and the dialog owns the toast sequence, so one owner serves the Like path and the dialog path alike. + +**A dialog kind in `CommandUiSpec`.** An action that consumes the token and runs a client callback is all the dialog needs; PR #3745 introduces the same `action` kind for its File row, so whichever lands second keeps one definition. + +## Consequences + +Adding a category means adding it to the union, to the Host tuple, to the dialog's chip record, and to the `feedback` dictionaries; the client bundle purity gate forbids a value import from a Host package, so the dialog restates the taxonomy as a `Record` whose key order is the chip order and whose completeness the compiler checks. The frozen released-v2 payload inventory still lists `feedback/record` as `text` only: it governs artifacts migrated from older generations, which cannot carry the new members, while equal-version restoration applies the installed vocabulary. The `message-feedback-layout` web scenario that pinned the popover's geometry is deleted with the popover. The message-feedback and feedback-release web goldens and the feedback subsystem doc changed in the same PR; the `/feedback ` command path and its SDK expected outputs are unchanged. diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md new file mode 100644 index 0000000000..7fe29c905f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 反馈弹窗、分类与确认 toast + +Status: implemented + +[English](2026-09-08-feedback-dialog-and-categories.md) | 中文 + +## 问题 + +Web 客户端有两条互不相连的反馈路径,且都没有可见结果。`/feedback ` 记录一条 Session 备注并在转录里渲染一行确认;赞踩对立即记录评分,自由文本则通过锚定在该行下方的备注浮层填写。两条路径都不告诉用户提交了什么、去了哪里,都不收集分类,而点踩这个用户最愿意解释的场景什么也不问。Issue #3515 及其设计稿要求:一个弹窗,可从输入框菜单、不带文本的 `/feedback` 和点踩三处打开,带七个固定分类、可选描述、成功 toast,以及已记录评分的实心图标;点赞保持立即记录。 + +## 决策 + +`command-feedback` 在其客户端可用的 `./types` 导出中以 `FeedbackCategory` 联合类型与 `FEEDBACK_CATEGORIES` 元组拥有分类表,`feedback/record` 变为 `{ text?, category? }`:空白文本记为缺省,两个成员都没有的条目仍会记录,因为反馈所授权的日志投递本身就是内容。同一个包通过 `TypertRemoteService` 发布 `sessionFeedback.record` Remote,按 id 找到 live Session 后调用已有的 `recordFeedback` 生产方,因此弹窗记录的是与命令相同的事件,只是没有命令簿记。`message-feedback` 给 `MessageFeedbackItem` 与 `MessageFeedbackPutRequest` 加上可选 `category`,按元组校验已存值,并把分类变化算作实质编辑。 + +`ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的差评,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用本 PR 给 `CommandUiSpec` 新增的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。点踩为消息打开同一个弹窗。点赞调用 `toggle`,它现在会报告自己提交的评分,因此该行只对记录成功的点赞做确认,撤回时保持沉默。备注浮层、`clearNote` 与 `clear` 被移除:弹窗是唯一的备注编辑器,切换评分只存判断本身,再次点击已记录的评分即撤回。 + +弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗带着失败码保持打开。 + +## 考虑过的替代方案 + +**把分类编进备注文本。** 自由文本里的前缀不解析就无法过滤,还会混进遥测上传的原样备注;载荷里的持久 id 才是消费方能分组的东西。 + +**让弹窗经命令平面以 `/feedback ` 提交。** 命令拒绝空文本、带不了分类,还会写一行设计稿已用 toast 取代的确认;Remote 记录同一个事件且没有这两个约束。 + +**在弹窗之外保留备注浮层。** 同一条备注有两个可达性不同的编辑器,会让该行在某些宽度下变成两行,正是当初引入浮层要避免的缺陷,而且设计稿只有两个拇指。 + +**每个消息控件各自一个 Toast。** 输入框浮层已经按 Session 挂载一次,弹窗又拥有 toast 序号,因此一个持有者同时服务点赞路径与弹窗路径。 + +**在 `CommandUiSpec` 里新增 dialog 种类。** 一个消费 token 后运行客户端回调的 action 已经够用;PR #3745 为它的「文件」行引入了同一个 `action` 种类,后合并的一方保留一份定义即可。 + +## 后果 + +新增分类意味着把它加进联合类型、宿主元组、弹窗的标签记录和 `feedback` 词典;客户端打包纯度门禁止从宿主包做值导入,因此弹窗以 `Record` 重述分类表,键的顺序就是标签顺序,完整性由编译器检查。冻结的已发布 v2 载荷清单仍把 `feedback/record` 列为仅有 `text`:它管辖从旧代际迁移来的产物,那些产物不可能携带新成员,而同版本恢复应用的是已安装词汇。固定浮层几何的 `message-feedback-layout` Web 场景随浮层一起删除。message-feedback 与 feedback-release 的 Web 期望输出和反馈子系统文档在同一个 PR 中更新;`/feedback ` 命令路径及其 SDK 期望输出不变。 diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index 58ffcd0469..e84a528a93 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -219,14 +219,17 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', const rated = page.getByRole('button', { name: 'Remove rating' }) await expect.poll(() => rated.getAttribute('aria-pressed')).toBe('true') await expectFeedbackRelease('feedback/message-put', 1) - await page.getByRole('button', { name: 'Add a note' }).click() - await page.getByRole('textbox', { name: 'Feedback note' }).fill('Read both files before answering.') + // Dislike collects the category and note in the dialog; typing releases nothing. + await page.getByRole('button', { name: 'Bad response' }).click() + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + await dialog.getByRole('button', { name: 'Task result', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Read both files before answering.') expect(captured()).toHaveLength(releasedCount) - await page.getByRole('button', { name: 'Save', exact: true }).click() - await page.getByText('Read both files before answering.', { exact: true }).waitFor() + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count()).toBe(0) await expectFeedbackRelease('feedback/message-put', 2) await rated.click() - await expect.poll(() => like.getAttribute('aria-pressed')).toBe('false') + await expect.poll(() => page.getByRole('button', { name: 'Bad response' }).getAttribute('aria-pressed')).toBe('false') await expectFeedbackRelease('feedback/message-delete', 1) const agent = scaffold.ctx.agents.get(sessionId) if (agent === undefined) throw new Error('feedback session has no active agent') @@ -238,7 +241,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', ]) expect(events.filter(event => event.type === 'feedback/message-put')).toMatchObject([ { data: { sessionId, item: { rating: 'positive' } } }, - { data: { sessionId, item: { rating: 'positive', note: 'Read both files before answering.' } } }, + { data: { sessionId, item: { rating: 'negative', note: 'Read both files before answering.', category: 'task-result' } } }, ]) expect(events.filter(event => event.type === 'feedback/message-delete')).toMatchObject([{ data: { sessionId } }]) expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1) @@ -250,7 +253,9 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', const feedback = events.flatMap>((event) => { switch (event.type) { case 'feedback/record': return [{ type: event.type, text: event.data.text }] - case 'feedback/message-put': return [{ type: event.type, rating: event.data.item.rating, note: event.data.item.note }] + case 'feedback/message-put': return [{ + type: event.type, rating: event.data.item.rating, note: event.data.item.note, category: event.data.item.category, + }] case 'feedback/message-delete': return [{ type: event.type }] default: return [] } diff --git a/apps/web/tests/message-feedback-layout.e2e.ts b/apps/web/tests/message-feedback-layout.e2e.ts deleted file mode 100644 index 1d3ee180af..0000000000 --- a/apps/web/tests/message-feedback-layout.e2e.ts +++ /dev/null @@ -1,336 +0,0 @@ -// Web e2e scenario: with the feedback note editor open, the assistant IconActions -// row stays one intact line (no wrapping, nothing pushed out), and the note -// editor floats above the transcript in a popover that escapes the conversation -// column's overflow clip and stays inside the viewport. -// -// The hazard this pins: a slot-contributed note editor (260px textarea plus -// Save and Cancel) cannot fit the shared IconActions row at ANY viewport, and an -// inline expansion made the row wider than the column — full-screen desktop -// included — so the branch action and the clock were pushed out of view by later -// flex items. The fix is to not mount the editor in the row at all: it is a -// popover portaled to document.body and fixed-positioned from the note trigger's -// rect, so the row keeps its single 28px line of icons and the trigger, and the -// panel cannot be cropped by the column's overflow because it lives outside it. -// -// The sweep records, per viewport, whether the open editor keeps the actions row -// on one line with zero overflow, whether the panel is outside the column (proof -// it escapes the clip), whether the panel stays inside the viewport (proof the -// clamp works), and whether it sits by its trigger. All relations, no absolute -// pixels: the column width follows the viewport, the sidebar, and the platform's -// scrollbar, so a golden carrying pixels would document the platform, not the -// behavior. -// -// Zero model calls: a settled transcript is cold-seeded, so nothing streams. -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { join } from 'node:path' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { - compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, - type WebScaffold, -} from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-feedback-layout', import.meta.url)) -/** - * Committed golden of the popover relations at every stop. Booleans and counts - * only, never absolute coordinates. - */ -const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') -const MODE = webSnapshotMode() -/** Borrowed read-only: this scenario needs any settled assistant message to rate. */ -const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) -const SEED_ID = 'message-feedback-layout-e2e' -/** Viewport widths from full-screen desktop down to a narrow window. */ -const WIDTHS = [1680, 1280, 1024, 900, 700, 600] - -/** One viewport stop: how the row reads with the note editor closed and open, plus the popover's own relations. */ -export interface PopoverMetrics { - /** Viewport width the stop was measured at. */ - width: number - /** The row's scrollable overflow with the note editor closed (natural row width). */ - rowOverflowClosed: number - /** The row's scrollable overflow with the note editor open; must equal the closed value. */ - rowOverflowOpen: number - /** Flex lines the row occupies with the note editor open; the editor must not reflow it. */ - rowLines: number - /** Row items whose right edge escapes the column, editor closed. */ - itemsOutsideColumnClosed: number - /** Row items whose right edge escapes the column, editor open; must equal the closed value. */ - itemsOutsideColumnOpen: number - /** True when the portaled panel is NOT inside the column (escapes its overflow clip). */ - panelOutsideColumn: boolean - /** True when the panel lies fully inside the viewport (the clamp holds). */ - panelWithinViewport: boolean - /** Horizontal separation between the panel's left edge and the note trigger's, in px. */ - panelToTriggerGap: number -} - -/** - * Measure the feedback row (and the open popover, when present) at the current - * viewport. The same reader serves the closed and open readings so the two - * sides differ only by whether the editor is open. - * @param page - the page under test. - * @param width - the viewport width already applied, recorded with the reading. - * @param editorOpen - true to also read the popover's relations; throws if it is absent. - * @returns the stop's relations. - */ -function measurePopover(page: Page, width: number, editorOpen: boolean): Promise { - return page.evaluate(({ viewportWidth, open }) => { - const rated = document.querySelector('button[aria-label="Remove rating"]') - if (rated === null) throw new Error('no rated feedback control in the DOM') - const row = rated.parentElement?.closest('div[class*="actions"]') ?? null - if (row === null) throw new Error('the IconActions row is not an ancestor of the feedback control') - const trigger = row.querySelector('button[aria-haspopup="dialog"]') - if (trigger === null) throw new Error('the note trigger is not in the row') - - /** - * The real flex items of the row. A slot contributor (the feedback strip) - * arrives as a `display: contents` wrapper (the `assistant-actions` slot - * renders inside a transparent `data-slot` div), which reports an all-zero - * rect; a zero box would be miscounted as a phantom flex line. The actual - * items are the boxes inside it. - * @param element - the row whose items to read. - * @returns the real flex-item boxes, in flex/DOM order. - */ - const flexItemBoxes = (element: HTMLElement): DOMRect[] => { - const boxes: DOMRect[] = [] - for (const child of Array.from(element.children)) { - const el = child as HTMLElement - const rect = el.getBoundingClientRect() - if (el.style.display === 'contents') { - boxes.push(...flexItemBoxes(el)) - } else if (rect.height > 0 && rect.width > 0) { - boxes.push(rect) - } - } - return boxes - } - /** - * Group items into flex lines by overlapping vertical extent. - * @param boxes - the row items' boxes, in DOM order. - * @returns the number of distinct lines. - */ - const countFlexLines = (boxes: DOMRect[]): number => { - const centres: number[] = [] - for (const box of boxes) { - const centre = box.top + box.height / 2 - if (!centres.some(known => Math.abs(known - centre) <= box.height / 2)) centres.push(centre) - } - return centres.length - } - - const column = row.closest('[data-conversation-scroll]') - const columnRight = (column?.getBoundingClientRect().left ?? 0) + (column?.clientWidth ?? 0) - const itemRects = flexItemBoxes(row) - // A half-pixel tolerance: subpixel layout puts a contained edge a fraction - // over the boundary on some device scale factors. - const itemsOutsideColumn = itemRects.filter(box => box.right > columnRight + 0.5).length - // The editor is a portal, so the row measures identically whether the - // editor is open or not; the closed/open fields differ by call so the sweep - // can assert a zero delta on them. - const overflow = row.scrollWidth - row.clientWidth - - let builder: { - panelOutsideColumn: boolean - panelWithinViewport: boolean - panelToTriggerGap: number - } - if (!open) { - builder = { panelOutsideColumn: true, panelWithinViewport: true, panelToTriggerGap: 0 } - } else { - const panel = document.body.querySelector('[role="dialog"]') - if (panel === null) throw new Error('the note popover is not open') - const panelBox = panel.getBoundingClientRect() - const triggerBox = trigger.getBoundingClientRect() - const vw = window.innerWidth - const vh = window.innerHeight - builder = { - // The panel portals out of the column, so the clip cannot reach it. - panelOutsideColumn: column === null ? true : !column.contains(panel), - panelWithinViewport: - panelBox.left >= -0.5 - && panelBox.right <= vw + 0.5 - && panelBox.top >= -0.5 - && panelBox.bottom <= vh + 0.5, - // The panel is fixed from the trigger's left, so a zero gap says it is - // anchored; a clamp can only widen it. - panelToTriggerGap: Math.abs(panelBox.left - triggerBox.left), - } - } - - return { - width: viewportWidth, - rowOverflowClosed: overflow, - rowOverflowOpen: overflow, - rowLines: countFlexLines(itemRects), - itemsOutsideColumnClosed: itemsOutsideColumn, - itemsOutsideColumnOpen: itemsOutsideColumn, - ...builder, - } - }, { viewportWidth: width, open: editorOpen }) -} - -/** - * Render the golden body: one line per stop, relations and counts only. The - * row-overflow and outside-column readings are deltas (open minus closed) so - * the golden records that opening the editor leaves the row untouched, not an - * absolute count that many unrelated controls could move. - * @param stops - the measured stops, in sweep order. - * @returns the golden body, without a trailing newline. - */ -function renderGeometry(stops: PopoverMetrics[]): string { - return [ - '# Assistant actions row with the feedback note popover open', - '', - '| viewport | row overflow delta | row lines | items-outside delta ' - + '| panel outside the column | panel within the viewport | panel-to-trigger gap |', - '| --- | --- | --- | --- | --- | --- | --- |', - ...stops.map(stop => `| ${String(stop.width)}px | ${String(stop.rowOverflowOpen - stop.rowOverflowClosed)}px ` - + `| ${String(stop.rowLines)} | ${String(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed)} ` - + `| ${String(stop.panelOutsideColumn)} | ${String(stop.panelWithinViewport)} ` - + `| ${String(stop.panelToTriggerGap)}px |`), - ].join('\n') -} - -describe('web e2e: the feedback note editor floats above the column', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType - - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) - browser = await chromium.launch() - page = await newEnglishPage(browser, 900) - tripwire = watchConsole(page) - await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - }, 180_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - /** - * Open the seeded transcript. The first treeitem is the collapsible group - * row; the session itself is the row beneath it. - * @returns nothing. - */ - async function openSeededSession(): Promise { - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 15_000 }) - await sessionRow.click() - } - - /** - * Resize to a viewport and read the row once its width stops moving. The - * frame eases its column tracks, so reading straight after a resize can - * report the previous viewport's relation. - * @param width - viewport width to settle at. - * @param editorOpen - whether the note editor is currently open; reads the popover relations when so. - * @returns the row's (and popover's) readings at that width. - */ - const settleAt = async (width: number, editorOpen: boolean): Promise => { - await page.setViewportSize({ width, height: 900 }) - let previous = -1 - await expect.poll(async () => { - const current = await page.evaluate(() => - document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1) - const settled = current === previous - previous = current - return settled - }, { timeout: 10_000 }).toBe(true) - // The popover is JS-positioned from the trigger rect and re-places on - // resize/scroll, so once the column width stops moving we nudge it to the - // final layout; otherwise the panel can sit at a transient position from - // mid-resize and the anchor reading would be off. - await page.evaluate(() => window.dispatchEvent(new Event('resize'))) - return measurePopover(page, width, editorOpen) - } - - /** - * Rate a message, then for every stop read the row once with the note editor - * closed and once with it open, handing the SAME measured readings to both - * assertions so the golden and the assertions describe one measurement - * rather than two runs that could disagree. - * @returns the stops in {@link WIDTHS} order. - */ - let swept: Promise | undefined - const sweep = (): Promise => { - swept ??= (async () => { - await openSeededSession() - await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) - // The controller defers its list read to the first hover or focus, so the - // strip has to be touched before it can be rated. - const like = page.getByRole('button', { name: 'Good response' }).first() - await like.waitFor({ timeout: 30_000 }) - await like.scrollIntoViewIfNeeded() - await like.hover() - await like.click() - await page.getByRole('button', { name: 'Remove rating' }).first() - .waitFor({ timeout: 15_000 }) - const noteTrigger = page.getByRole('button', { name: 'Add a note' }).first() - const stops: PopoverMetrics[] = [] - for (const width of WIDTHS) { - // Reset to the closed baseline at each stop before opening. - if (await noteTrigger.getAttribute('aria-expanded') === 'true') await noteTrigger.click() - const closed = await settleAt(width, false) - await page.getByRole('button', { name: 'Add a note' }).first().click() - await page.getByRole('dialog').waitFor({ timeout: 10_000 }) - const open = await settleAt(width, true) - stops.push({ - width, - rowOverflowClosed: closed.rowOverflowClosed, - rowOverflowOpen: open.rowOverflowOpen, - rowLines: open.rowLines, - itemsOutsideColumnClosed: closed.itemsOutsideColumnClosed, - itemsOutsideColumnOpen: open.itemsOutsideColumnOpen, - panelOutsideColumn: open.panelOutsideColumn, - panelWithinViewport: open.panelWithinViewport, - panelToTriggerGap: open.panelToTriggerGap, - }) - } - return stops - })() - return swept - } - - it('keeps the actions row untouched by the note popover, which stays in the viewport', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout')) - const stops = await sweep() - for (const stop of stops) { - // The popover lives outside the row, so opening it must not change the - // row at all. This is the vacuity guard of the whole redesign: an inline - // editor would widen or reflow the row, pushing the delta off zero. - expect(stop.rowOverflowOpen - stop.rowOverflowClosed, `viewport ${String(stop.width)}`).toBe(0) - expect(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed, `viewport ${String(stop.width)}`).toBe(0) - // The row is one 28px line; the editor never forces a reflow. - expect(stop.rowLines, `viewport ${String(stop.width)}`).toBe(1) - // The panel escapes the column's overflow clip by living outside it. - expect(stop.panelOutsideColumn, `viewport ${String(stop.width)}`).toBe(true) - // The placement clamps the panel inside the viewport at every width. - expect(stop.panelWithinViewport, `viewport ${String(stop.width)}`).toBe(true) - // The panel stays anchored to its trigger rather than drifting off. - expect(stop.panelToTriggerGap, `viewport ${String(stop.width)}`).toBeLessThanOrEqual(4) - } - expect(tripwire.pageErrors).toEqual([]) - }, 180_000) - - it('matches the committed geometry golden', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout-golden')) - await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE) - }, 180_000) - - it('kept the console clean', () => { - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - }) -}) diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index 62cdb33ef2..451ae3b851 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -1,11 +1,13 @@ // Keyless browser regression for durable per-message feedback. Cold-seeds a -// settled two-turn transcript (zero model calls), rates one assistant message, -// attaches a note, proves both survive a full page reload from the Host's -// message-feedback sidecar, then retracts the rating. +// settled two-turn transcript (zero model calls), likes one assistant message +// and sees the acknowledgement, replaces the Like through the Dislike dialog +// with a category and a note, proves the judgment survives a full page reload +// from the Host's canonical log, then retracts it. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' +import { SessionId } from '@deepseek-ai/dsh-session' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { acknowledgeReloadConnectionLoss, launchWebScaffold, @@ -56,7 +58,7 @@ describe('web e2e: durable per-message feedback', () => { await sessionRow.click() } - it.skipIf(MODE === 'record')('persists a rating and its note across a reload, then retracts', async () => { + it.skipIf(MODE === 'record')('persists a Dislike with its category and note across a reload, then retracts', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback')) await openSeededSession() @@ -69,18 +71,23 @@ describe('web e2e: durable per-message feedback', () => { await like.scrollIntoViewIfNeeded() await like.hover() await like.click() - // A recorded rating relabels the button to what the next click would do, - // so the pressed control is addressed by the retract label from here on. + // A Like records at once and is acknowledged; a recorded rating relabels + // the button to what the next click would do. + await page.getByRole('alert').filter({ hasText: 'Thanks for your feedback' }).waitFor({ timeout: 10_000 }) const rated = page.getByRole('button', { name: 'Remove rating' }).first() await expect.poll(() => rated.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('true') - // A rated message offers the note editor; an unrated one does not. - await page.getByRole('button', { name: 'Add a note' }).first().click() - const editor = page.getByRole('textbox', { name: 'Feedback note' }) - await editor.fill(NOTE) - await page.getByRole('button', { name: 'Save', exact: true }).click() - await expect.poll(() => editor.count(), { timeout: 10_000 }).toBe(0) - await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 }) + // Dislike opens the Session's feedback dialog; its submission replaces + // the Like with a negative judgment carrying the category and note. + await page.getByRole('button', { name: 'Bad response' }).first().click() + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'Task result', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill(NOTE) + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => rated.getAttribute('aria-label'), { timeout: 10_000 }).toBe('Remove rating') + await expect.poll(() => like.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false') // The durable assertion: a cold browser re-reads the sidecar over the wire. const warningStart = tripwire.warnings.length @@ -103,15 +110,22 @@ describe('web e2e: durable per-message feedback', () => { await restored.scrollIntoViewIfNeeded() await restored.hover() await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true') - await page.getByText(NOTE, { exact: true }).waitFor({ timeout: 10_000 }) + // The retract label sits on the Dislike side: the Like stays unpressed. + await expect.poll(() => cold.getAttribute('aria-pressed'), { timeout: 10_000 }).toBe('false') + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + const put = agent.session.snapshotEvents().filter(event => event.type === 'feedback/message-put').at(-1) + expect(put?.type === 'feedback/message-put' ? put.data.item : undefined) + .toMatchObject({ rating: 'negative', note: NOTE, category: 'task-result' }) // Re-clicking the active rating retracts it, and the note goes with it. await restored.click() await expect.poll( - () => page.getByRole('button', { name: 'Good response' }).first().getAttribute('aria-pressed'), + () => page.getByRole('button', { name: 'Bad response' }).first().getAttribute('aria-pressed'), { timeout: 10_000 }, ).toBe('false') - await expect.poll(() => page.getByText(NOTE, { exact: true }).count(), { timeout: 10_000 }).toBe(0) + const last = agent.session.snapshotEvents().at(-1) + expect(last?.type).toBe('feedback/message-delete') }, 90_000) it.skipIf(MODE === 'record')('kept the console clean', () => { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 2a25226d5f..e05cd9eb3c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -70,7 +70,6 @@ "tests/message-actions.e2e.ts", "tests/open-in-app-ssh.e2e.ts", "tests/message-feedback.e2e.ts", - "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", "tests/reference-composer.e2e.ts", "tests/markdown-wide-table.e2e.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index c370fbef7f..ab602f097d 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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 docs/capability-seams.md -capability-seams.md: a47ec285e248c4d969e49bc941d5c93faef7b65e -capability-seams.zh.md: 42e7105611f21f74ee0d8088e06e5c7007af9376 +capability-seams.md: 9cc7a6fc50b81e9f82d58600c84d1ff6494634c9 +capability-seams.zh.md: e898bdf3c2c728a1bae427bbb737eb5269a1c500 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a47ec285e2..9cc7a6fc50 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -82,6 +82,8 @@ flowchart LR svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] + pkg_command_feedback["command-feedback"] + svc_sessionFeedback["ctx.sessionFeedback
Session-level feedback recorder"] svc_workspaceRegistry["ctx.workspaceRegistry
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -244,6 +246,7 @@ flowchart LR pkg_client_modules --> svc_clientModules pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker_thread --> svc_codeRuntime + pkg_command_feedback --> svc_sessionFeedback pkg_commands --> svc_commands pkg_compaction --> svc_compaction pkg_compaction_basic --> svc_compaction @@ -498,6 +501,7 @@ flowchart LR | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns per-assistant-message feedback in the canonical Session log, target validation, per-item compare-and-set, and the Host unary Remote contract. Feedback stays outside model history; log export follows the consumer policy. | +| `ctx.sessionFeedback` | `core` | [`command-feedback`](../packages/feedback/command-feedback) | - | - | - | Records one Session-level remark with its category as a log-only feedback/record event on a live Session through the Host unary Remote contract; the /feedback command shares the same producer. | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | [`api-workspace-controller`](../packages/api/workspace-controller), [`api-session-controller`](../packages/api/session-controller) | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | The interface returns path-only completion candidates within an Agent cwd; providers own namespace access and ranking without reading file contents. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 42e7105611..e898bdf3c2 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -84,6 +84,8 @@ flowchart LR svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] + pkg_command_feedback["command-feedback"] + svc_sessionFeedback["ctx.sessionFeedback
Session-level feedback recorder"] svc_workspaceRegistry["ctx.workspaceRegistry
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -246,6 +248,7 @@ flowchart LR pkg_client_modules --> svc_clientModules pkg_code_runtime --> svc_codeRuntime pkg_code_runtime_worker_thread --> svc_codeRuntime + pkg_command_feedback --> svc_sessionFeedback pkg_commands --> svc_commands pkg_compaction --> svc_compaction pkg_compaction_basic --> svc_compaction @@ -500,6 +503,7 @@ flowchart LR | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有权威 Session 日志中的逐 assistant 消息反馈、目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约。反馈不进入模型历史;日志导出遵循消费方策略。 | +| `ctx.sessionFeedback` | `core` | [`command-feedback`](../packages/feedback/command-feedback) | - | - | - | 通过 Host 一元 Remote 契约在 live Session 上把一条带分类的 Session 级评价记录为仅写日志的 feedback/record 事件;/feedback 命令共用同一个生产方。 | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | [`api-workspace-controller`](../packages/api/workspace-controller), [`api-session-controller`](../packages/api/session-controller) | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | | `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | 该接口返回 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问与排序,但不读取文件内容。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2c01491512..8027ff1fc4 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: a1cc7d72898367cb4a752c10355d5c4808d1c6c7 -config-catalog.zh.md: 88e656f0ac0a8132638259e0ab089888b633d446 +config-catalog.md: d24d7a605e5ba2234285ebe56e6f3bd86541eaaa +config-catalog.zh.md: e16d4f7b1206a274f566dd2b42c4eaa0344b2126 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a1cc7d7289..d24d7a605e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1590,7 +1590,7 @@ export interface Config { } ``` -Source: [`packages/feedback/message-feedback/src/index.ts:39`](../packages/feedback/message-feedback/src/index.ts) +Source: [`packages/feedback/message-feedback/src/index.ts:40`](../packages/feedback/message-feedback/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 88e656f0ac..e16d4f7b12 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1592,7 +1592,7 @@ export interface Config { } ``` -来源:[`packages/feedback/message-feedback/src/index.ts:39`](../packages/feedback/message-feedback/src/index.ts) +来源:[`packages/feedback/message-feedback/src/index.ts:40`](../packages/feedback/message-feedback/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index b1f10816f3..09eecc6d42 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 449b7d8f0fb55515e7f1f028e151ce1b26862e92 -event-producer-consumer.zh.md: 8120df6de6776bce8841f75311294583417532c2 +event-producer-consumer.md: 2239c9786f9e18b6a8e9514bfd53da7f87cfb8a4 +event-producer-consumer.zh.md: e43d48e2ee163843752bcda9fe3578839e05252b diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 449b7d8f0f..2239c9786f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -39,7 +39,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | -| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:57`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | +| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 8120df6de6..e43d48e2ee 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -41,7 +41,7 @@ | `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:102`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:90`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`credentials`](../packages/credentials/credentials), `remotes` | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace), `workspace-controller` | -| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:57`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | +| `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d1623e33a3..c1c63bd350 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 45e3490e95c86b025fde3f89ef3243b98625d012 -module-graph.zh.md: aacce9fb513e243a1ec27955d34aa82a2a6749be +module-graph.md: 24a53712e3956d2f701b0e0db27b65a6c1c1dd3e +module-graph.zh.md: dc65c4516ed803216fbb3a1cd5a7369115fad498 diff --git a/docs/module-graph.md b/docs/module-graph.md index 45e3490e95..24a53712e3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -474,11 +474,6 @@ flowchart TD pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence - pkg_message_feedback --> pkg_brand - pkg_message_feedback --> pkg_llm - pkg_message_feedback --> pkg_session - pkg_message_feedback --> pkg_session_persistence - pkg_message_feedback --> pkg_typert_protocol pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session @@ -668,6 +663,7 @@ flowchart TD pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_typert_protocol pkg_permission_presets --> pkg_commands pkg_permission_presets --> pkg_invariants pkg_permission_presets --> pkg_sandbox @@ -803,6 +799,12 @@ flowchart TD pkg_cordis_host_runner --> pkg_session pkg_cordis_host_runner --> pkg_tools pkg_cordis_host_runner --> pkg_typert_protocol + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_command_feedback + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_typert_protocol pkg_repeat_tool_reminder --> pkg_agent pkg_repeat_tool_reminder --> pkg_tools pkg_tool_call_timeout_policy --> pkg_llm @@ -852,12 +854,6 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools - pkg_session_telemetry_otel --> pkg_anonymous_user_id - pkg_session_telemetry_otel --> pkg_command_feedback - pkg_session_telemetry_otel --> pkg_llm - pkg_session_telemetry_otel --> pkg_message_feedback - pkg_session_telemetry_otel --> pkg_session - pkg_session_telemetry_otel --> pkg_session_telemetry pkg_session_title_all_prompts_llm --> pkg_llm pkg_session_title_all_prompts_llm --> pkg_session pkg_session_title_all_prompts_llm --> pkg_session_title @@ -937,6 +933,12 @@ flowchart TD pkg_host_plugin_inventory --> pkg_agent_presets pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol + pkg_session_telemetry_otel --> pkg_anonymous_user_id + pkg_session_telemetry_otel --> pkg_command_feedback + pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_message_feedback + pkg_session_telemetry_otel --> pkg_session + pkg_session_telemetry_otel --> pkg_session_telemetry pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_jobs pkg_tool_bash --> pkg_llm @@ -1321,7 +1323,6 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1364,7 +1365,7 @@ flowchart TD | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | @@ -1389,6 +1390,7 @@ flowchart TD | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | @@ -1398,7 +1400,6 @@ flowchart TD | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | @@ -1415,6 +1416,7 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index aacce9fb51..dc65c4516e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -476,11 +476,6 @@ flowchart TD pkg_spill_local --> pkg_spill pkg_session_log_export --> pkg_session pkg_session_log_export --> pkg_session_persistence - pkg_message_feedback --> pkg_brand - pkg_message_feedback --> pkg_llm - pkg_message_feedback --> pkg_session - pkg_message_feedback --> pkg_session_persistence - pkg_message_feedback --> pkg_typert_protocol pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox pkg_sandbox_local --> pkg_session @@ -670,6 +665,7 @@ flowchart TD pkg_command_feedback --> pkg_anonymous_user_id pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_typert_protocol pkg_permission_presets --> pkg_commands pkg_permission_presets --> pkg_invariants pkg_permission_presets --> pkg_sandbox @@ -805,6 +801,12 @@ flowchart TD pkg_cordis_host_runner --> pkg_session pkg_cordis_host_runner --> pkg_tools pkg_cordis_host_runner --> pkg_typert_protocol + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_command_feedback + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_typert_protocol pkg_repeat_tool_reminder --> pkg_agent pkg_repeat_tool_reminder --> pkg_tools pkg_tool_call_timeout_policy --> pkg_llm @@ -854,12 +856,6 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools - pkg_session_telemetry_otel --> pkg_anonymous_user_id - pkg_session_telemetry_otel --> pkg_command_feedback - pkg_session_telemetry_otel --> pkg_llm - pkg_session_telemetry_otel --> pkg_message_feedback - pkg_session_telemetry_otel --> pkg_session - pkg_session_telemetry_otel --> pkg_session_telemetry pkg_session_title_all_prompts_llm --> pkg_llm pkg_session_title_all_prompts_llm --> pkg_session pkg_session_title_all_prompts_llm --> pkg_session_title @@ -939,6 +935,12 @@ flowchart TD pkg_host_plugin_inventory --> pkg_agent_presets pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_typert_protocol + pkg_session_telemetry_otel --> pkg_anonymous_user_id + pkg_session_telemetry_otel --> pkg_command_feedback + pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_message_feedback + pkg_session_telemetry_otel --> pkg_session + pkg_session_telemetry_otel --> pkg_session_telemetry pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_jobs pkg_tool_bash --> pkg_llm @@ -1323,7 +1325,6 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | -| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | | [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) | @@ -1366,7 +1367,7 @@ flowchart TD | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`permission-presets`](../packages/interaction/permission-presets) | `interaction` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`user-approval`](../packages/interaction/user-approval) | | [`jobs-local`](../packages/jobs/jobs-local) | `jobs` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`scope`](../packages/core/scope), [`timeout`](../packages/util/timeout) | | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | @@ -1391,6 +1392,7 @@ flowchart TD | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | @@ -1400,7 +1402,6 @@ flowchart TD | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | @@ -1417,6 +1418,7 @@ flowchart TD | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 8049e2d430..32fb554a60 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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 docs/persistence-catalog.md -persistence-catalog.md: a9e1221a564a4ad1af1353278f1215bb33fb9c4f -persistence-catalog.zh.md: 2b74b03b4b783f848385e66e15c40d73f50a788b +persistence-catalog.md: c83e98e2722430b29967dcb0cbc7052834c73ecc +persistence-catalog.zh.md: 187c39797d94e6c38ca38833281fb3650fe6463e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a9e1221a56..c83e98e272 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -422,7 +422,7 @@ Source: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present 'feedback/message-delete': MessageFeedbackDelete ``` -Source: [`packages/feedback/message-feedback/src/types.ts:55`](../packages/feedback/message-feedback/src/types.ts) +Source: [`packages/feedback/message-feedback/src/types.ts:58`](../packages/feedback/message-feedback/src/types.ts) @@ -433,7 +433,7 @@ Source: [`packages/feedback/message-feedback/src/types.ts:55`](../packages/feedb 'feedback/message-put': MessageFeedbackPut ``` -Source: [`packages/feedback/message-feedback/src/types.ts:53`](../packages/feedback/message-feedback/src/types.ts) +Source: [`packages/feedback/message-feedback/src/types.ts:56`](../packages/feedback/message-feedback/src/types.ts) @@ -444,10 +444,10 @@ Source: [`packages/feedback/message-feedback/src/types.ts:53`](../packages/feedb * One recorded human remark about this session. Log-only and independent * of its trigger; it never enters model context or derived history. */ -'feedback/record': { text: string } +'feedback/record': FeedbackRecord ``` -Source: [`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) +Source: [`packages/feedback/command-feedback/src/types.ts:54`](../packages/feedback/command-feedback/src/types.ts) ### `goal/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 2b74b03b4b..187c39797d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -446,10 +446,10 @@ export type SessionEvent = { * One recorded human remark about this session. Log-only and independent * of its trigger; it never enters model context or derived history. */ -'feedback/record': { text: string } +'feedback/record': FeedbackRecord ``` -来源:[`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) +来源:[`packages/feedback/command-feedback/src/types.ts:54`](../packages/feedback/command-feedback/src/types.ts) ### `goal/*` diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml index db8b17556f..860ebb06ca 100644 --- a/docs/subsystems/feedback.i18n.yaml +++ b/docs/subsystems/feedback.i18n.yaml @@ -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 docs/subsystems/feedback.md -feedback.md: c538fac50b0c75e43f761a3b58b385840b83e4ef -feedback.zh.md: 4a77f9c0396f3635755c301a80ccaefe398d5ad6 +feedback.md: b3dec08a88a5c8373b06ac62cbbe23e18c6da12a +feedback.zh.md: 4e38f6d951904abe253bfd160dc5d26c252e5ab0 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md index c538fac50b..b3dec08a88 100644 --- a/docs/subsystems/feedback.md +++ b/docs/subsystems/feedback.md @@ -2,7 +2,7 @@ English | [中文](feedback.zh.md) -[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. The canonical Session log stores `feedback/message-put` and `feedback/message-delete`; the immutable Session-level remark remains `feedback/record`. All three are log-only events that never enter model context. +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. The canonical Session log stores `feedback/message-put` and `feedback/message-delete`; the immutable Session-level remark remains `feedback/record`, owned by [`@deepseek-ai/dsh-command-feedback`](../../packages/feedback/command-feedback) together with the `FeedbackCategory` taxonomy both kinds of feedback file under. All three are log-only events that never enter model context. Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) @@ -27,6 +27,8 @@ interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string + /** Category the human filed a negative judgment under. */ + readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion /** Host-assigned creation time in Unix epoch milliseconds. */ @@ -83,6 +85,8 @@ interface MessageFeedbackPutRequest { readonly rating: MessageFeedbackRating /** Optional non-blank explanation. */ readonly note?: string + /** Optional category; absent keeps the item uncategorized. */ + readonly category?: FeedbackCategory /** Observed item version, or `null` to require that no item exists. */ readonly ifVersion: MessageFeedbackVersion | null } @@ -203,11 +207,76 @@ type MessageFeedbackDeleteResult = | MessageFeedbackRejected ``` +## Session feedback types + +Source: [`packages/feedback/command-feedback/src/types.ts`](../../packages/feedback/command-feedback/src/types.ts) + +```ts type-equiv +/** One of the fixed feedback categories; the ids are durable log vocabulary. */ +type FeedbackCategory = + | 'task-result' + | 'instruction-following' + | 'product-interaction' + | 'service-stability' + | 'resource-cost' + | 'security-privacy-permission' + | 'other' +``` + +```ts type-equiv +/** + * One recorded human remark about a Session. Both members are optional: a + * submission with neither still records that the human asked for the + * Session to be reviewed, which is what authorizes log delivery. + */ +interface FeedbackRecord { + /** Free-text remark with surrounding whitespace removed; never empty when present. */ + readonly text?: string + /** Category the human filed the remark under. */ + readonly category?: FeedbackCategory +} +``` + +```ts type-equiv +/** Record one Session-level remark through the Host Remote. */ +interface SessionFeedbackRecordRequest { + /** Live Session the remark describes. */ + readonly sessionId: SessionId + /** Free-text remark; blank text is recorded as absent. */ + readonly text?: string + /** Category the human filed the remark under. */ + readonly category?: FeedbackCategory +} +``` + +```ts type-equiv +/** Stable postcondition of a recorded remark. */ +interface SessionFeedbackRecordValue { + /** The remark is appended to the Session log; flushing follows the Session's own schedule. */ + readonly recorded: true +} +``` + +```ts type-equiv +/** No live Session carries the requested id. */ +interface SessionFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Result returned by the `sessionFeedback.record` operation. */ +type SessionFeedbackRecordResult = + | { readonly ok: true; readonly value: SessionFeedbackRecordValue } + | { readonly ok: false; readonly error: SessionFeedbackSessionNotFound } +``` + ## Data and concurrency -Current items are folded from canonical feedback events whose payload `sessionId` matches the owning Session. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them. +Current items are folded from canonical feedback events whose payload `sessionId` matches the owning Session. Each item carries a positive or negative rating, an optional note, an optional category, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them. -`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op. A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue serializes reads and mutations; cold mutations hold a persistence write handle across read, comparison, append, and flush. Matching no-ops append no event. +`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op (a put repeating the stored rating, note, and category). A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue serializes reads and mutations; cold mutations hold a persistence write handle across read, comparison, append, and flush. Matching no-ops append no event. ## Target and lifecycle authority @@ -217,7 +286,7 @@ Fork seeds can contain parent feedback events, but their payload retains the par ## Persistence and Remote contract -Successful message-feedback mutations await canonical persistence: live operations append through the owning Session and require a participating `ctx.sessions.flush` listener; cold operations append and flush through their write handle. Persistence failures propagate rather than reporting success. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `TypertRemoteService` and `@Remote`; the generated Cordis API below is the method-level authority. +Successful message-feedback mutations await canonical persistence: live operations append through the owning Session and require a participating `ctx.sessions.flush` listener; cold operations append and flush through their write handle. Persistence failures propagate rather than reporting success. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `TypertRemoteService` and `@Remote`; `command-feedback` publishes `sessionFeedback.record` the same way for Session-level remarks on live Sessions. The generated Cordis API below is the method-level authority. Plugin disposal closes operation admission and drains accepted per-Session queue work. @@ -225,12 +294,14 @@ When explicitly enabled, [`session-log-deepseek`](../../packages/session/session ## Web surface -[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) is the browser consumer. `@deepseek-ai/dsh-api-remotes` mounts the generated `messageFeedback` contribution, so the plugin calls `ctx.remote.messageFeedback` and never touches the transport. +[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) is the browser consumer. `@deepseek-ai/dsh-api-remotes` mounts the generated `messageFeedback` and `sessionFeedback` contributions, so the plugin calls `ctx.remote.messageFeedback` and `ctx.remote.sessionFeedback` and never touches the transport. The controls are the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` list slot, which `ui-conversation` declares and renders inside the finalized assistant message's IconActions row. `AssistantMessageNode` carries the optional `messageId` from the `assistant/message` event. The field is absent on interruption-frozen partials, and the render site skips the slot when it is absent. The strip renders once per turn, on the closing assistant message: the Host accepts every append-origin step message as a target, but earlier steps of a multi-step turn render tool rows rather than a rateable body, so the UI exposes a narrower set than the Host contract allows. One `MessageFeedbackController` per Session backs every message control in that Session: a single `list` read seeds the whole transcript, deferred to first hover or focus rather than fired on mount. Each mutation sends the version that controller last observed as `ifVersion`; a `version-conflict` reply carries the authoritative item, so the controller reconciles from the reply instead of refetching. Mutations serialize per Session so a queued operation compares against the committed version. A `connection/reset` refreshes only Sessions already read. +Like records the bare positive judgment at once and shows the acknowledgement toast. Dislike opens the Session's feedback dialog, the `feedback-dialog` entry of `conversation.input.overlay`: the shared Modal card with seven category chips and a detail box. Submit puts a negative judgment carrying the chosen category and the trimmed description, or neither. The same dialog opens for the Session from a bare `/feedback` — a decoration `ui-commands` routes as an `action` — and then records through `sessionFeedback.record`; `/feedback ` keeps the Host command path. Clicking a recorded rating retracts it. + ## Boundaries and limitations - The operation queue is process-local; cold writer exclusion relies on the selected persistence provider. @@ -240,7 +311,8 @@ One `MessageFeedbackController` per Session backs every message control in that - The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary. - The Web controls appear in the chat view only. The trajectory and waterfall views render no feedback entry even though their assistant nodes carry the same `messageId`. - The Web controller does not consume feedback log events, so a second tab's rating becomes visible on reconnect or on the next conflict reply rather than immediately. -- The note editor does not pre-check `maxNoteBytes`; an oversized note fails on save with `note-too-large` rather than while typing. +- The dialog does not pre-check `maxNoteBytes`; an oversized description fails on submit with `note-too-large` rather than while typing. +- `sessionFeedback.record` serves live Sessions only and answers `session-not-found` otherwise; the dialog reports that failure when its Session retires while it is open. @@ -282,6 +354,24 @@ Session-log service; cold operations never construct a Session or Agent. Source: [`packages/feedback/message-feedback/src/index.ts`](../../packages/feedback/message-feedback/src/index.ts) + + +### `ctx.sessionFeedback` — `SessionFeedbackService` + +Host Remote through which a product surface records a Session-level remark. + +```ts cordis-catalog +/** + * Record one remark on a live Session. + * @param request - target Session plus the optional text and category. + * @returns the recorded postcondition, or `session-not-found` when no live + * Session carries the id. + */ +@Remote('record') record(request: SessionFeedbackRecordRequest): Promise +``` + +Source: [`packages/feedback/command-feedback/src/index.ts`](../../packages/feedback/command-feedback/src/index.ts) + ### `feedback/*` events diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md index 4a77f9c039..4e38f6d951 100644 --- a/docs/subsystems/feedback.zh.md +++ b/docs/subsystems/feedback.zh.md @@ -2,7 +2,7 @@ [English](feedback.md) | 中文 -[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。权威 Session 日志保存 `feedback/message-put` 和 `feedback/message-delete`;不可变的 Session 级备注仍使用 `feedback/record`。三者都是仅写日志的事件,绝不进入模型上下文。 +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。权威 Session 日志保存 `feedback/message-put` 和 `feedback/message-delete`;不可变的 Session 级备注仍使用 `feedback/record`,由 [`@deepseek-ai/dsh-command-feedback`](../../packages/feedback/command-feedback) 连同两种反馈共用的 `FeedbackCategory` 分类表一起拥有。三者都是仅写日志的事件,绝不进入模型上下文。 来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) @@ -27,6 +27,8 @@ interface MessageFeedbackItem { readonly rating: MessageFeedbackRating /** Optional explanation, preserved verbatim after validation. */ readonly note?: string + /** Category the human filed a negative judgment under. */ + readonly category?: FeedbackCategory /** Equality-only token replaced by every material create or update. */ readonly version: MessageFeedbackVersion /** Host-assigned creation time in Unix epoch milliseconds. */ @@ -83,6 +85,8 @@ interface MessageFeedbackPutRequest { readonly rating: MessageFeedbackRating /** Optional non-blank explanation. */ readonly note?: string + /** Optional category; absent keeps the item uncategorized. */ + readonly category?: FeedbackCategory /** Observed item version, or `null` to require that no item exists. */ readonly ifVersion: MessageFeedbackVersion | null } @@ -203,11 +207,76 @@ type MessageFeedbackDeleteResult = | MessageFeedbackRejected ``` +## Session 反馈类型 + +来源:[`packages/feedback/command-feedback/src/types.ts`](../../packages/feedback/command-feedback/src/types.ts) + +```ts type-equiv +/** One of the fixed feedback categories; the ids are durable log vocabulary. */ +type FeedbackCategory = + | 'task-result' + | 'instruction-following' + | 'product-interaction' + | 'service-stability' + | 'resource-cost' + | 'security-privacy-permission' + | 'other' +``` + +```ts type-equiv +/** + * One recorded human remark about a Session. Both members are optional: a + * submission with neither still records that the human asked for the + * Session to be reviewed, which is what authorizes log delivery. + */ +interface FeedbackRecord { + /** Free-text remark with surrounding whitespace removed; never empty when present. */ + readonly text?: string + /** Category the human filed the remark under. */ + readonly category?: FeedbackCategory +} +``` + +```ts type-equiv +/** Record one Session-level remark through the Host Remote. */ +interface SessionFeedbackRecordRequest { + /** Live Session the remark describes. */ + readonly sessionId: SessionId + /** Free-text remark; blank text is recorded as absent. */ + readonly text?: string + /** Category the human filed the remark under. */ + readonly category?: FeedbackCategory +} +``` + +```ts type-equiv +/** Stable postcondition of a recorded remark. */ +interface SessionFeedbackRecordValue { + /** The remark is appended to the Session log; flushing follows the Session's own schedule. */ + readonly recorded: true +} +``` + +```ts type-equiv +/** No live Session carries the requested id. */ +interface SessionFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Result returned by the `sessionFeedback.record` operation. */ +type SessionFeedbackRecordResult = + | { readonly ok: true; readonly value: SessionFeedbackRecordValue } + | { readonly ok: false; readonly error: SessionFeedbackSessionNotFound } +``` + ## 数据与并发 -当前条目由 payload 中 `sessionId` 与所属 Session 匹配的权威反馈事件归约得到。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。 +当前条目由 payload 中 `sessionId` 与所属 Session 匹配的权威反馈事件归约得到。每个条目携带好评或差评、可选备注、可选分类、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。 -`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列串行执行读取与变更;cold 变更在读取、比较、追加和 flush 期间持有持久化写句柄。匹配版本的无变更操作不追加事件。 +`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值(重复已存评分、备注与分类的 put)。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列串行执行读取与变更;cold 变更在读取、比较、追加和 flush 期间持有持久化写句柄。匹配版本的无变更操作不追加事件。 ## 目标与生命周期权威 @@ -217,7 +286,7 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s ## 持久化与 Remote 约定 -成功的消息反馈变更会等待权威持久化完成:live 操作通过所属 Session 追加,并要求有 `ctx.sessions.flush` 监听器参与;cold 操作通过写句柄追加并 flush。持久化故障会原样传播,不会报告成功。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `TypertRemoteService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 约定;下方生成的 Cordis API 是方法级权威。 +成功的消息反馈变更会等待权威持久化完成:live 操作通过所属 Session 追加,并要求有 `ctx.sessions.flush` 监听器参与;cold 操作通过写句柄追加并 flush。持久化故障会原样传播,不会报告成功。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `TypertRemoteService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 约定;`command-feedback` 以同样方式发布面向 live Session 的 Session 级备注 `sessionFeedback.record`。下方生成的 Cordis API 是方法级权威。 插件释放会关闭操作接纳,并排空已进入各 Session 队列的工作。 @@ -225,12 +294,14 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s ## Web 界面 -[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) 是浏览器侧消费方。`@deepseek-ai/dsh-api-remotes` 挂载生成的 `messageFeedback` 贡献,因此该插件调用 `ctx.remote.messageFeedback`,不接触传输层。 +[`@deepseek-ai/dsh-client-ui-message-feedback`](../../packages/client/ui-message-feedback) 是浏览器侧消费方。`@deepseek-ai/dsh-api-remotes` 挂载生成的 `messageFeedback` 与 `sessionFeedback` 贡献,因此该插件调用 `ctx.remote.messageFeedback` 与 `ctx.remote.sessionFeedback`,不接触传输层。 控件是 `conversation.chat.assistant-actions` list slot 的 `feedback` 条目(order 10),该 slot 由 `ui-conversation` 声明,并渲染在已定稿助手消息的 IconActions 行内。`AssistantMessageNode` 携带来自 `assistant/message` 事件的可选 `messageId`。被中断冻结的部分输出没有该字段,渲染点在字段缺失时跳过该 slot。该操作栏每个 Turn 渲染一次,位于收尾的助手消息上:Host 接受每条 append-origin 步骤消息作为目标,但多步骤 Turn 中较早的步骤渲染的是工具行而非可评分正文,因此 UI 暴露的范围比 Host 约定允许的更窄。 每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件:一次 `list` 读取即填充整段对话,且延迟到首次 hover 或 focus 才发起,而非挂载时触发。每次变更把该 controller 最后观察到的版本作为 `ifVersion` 发送;`version-conflict` 响应携带权威条目,controller 据此对账而不重新拉取。变更按 Session 串行,排队操作与已提交版本比较。`connection/reset` 只刷新已读取过的 Session。 +点赞立即记录不带备注的好评并显示确认 toast。点踩打开该 Session 的反馈弹窗,即 `conversation.input.overlay` 的 `feedback-dialog` 条目:共用的 Modal 卡片,里面是七个分类标签和一个详情框。提交会 put 一条差评,带上所选分类与去除首尾空白的描述,两者也可都不带。不带文本的 `/feedback`(`ui-commands` 以 `action` 路由的一个装饰)为 Session 打开同一个弹窗,随后通过 `sessionFeedback.record` 记录;`/feedback ` 仍走宿主命令路径。再次点击已记录的评分会撤回它。 + ## 边界与限制 - 操作队列仅在进程内生效;cold 写入排他性依赖所选持久化提供方。 @@ -240,7 +311,8 @@ fork 种子可以包含父 Session 的反馈事件,但 payload 保留父级 `s - Host 约定不记录已认证的 actor 或审计身份,因此假设调用方边界可信。 - Web 控件只出现在对话视图。trajectory 与 waterfall 视图不渲染反馈条目,尽管它们的助手节点携带相同的 `messageId`。 - Web 控制器不消费反馈日志事件,因此另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现。 -- 备注编辑器不预先校验 `maxNoteBytes`;超长备注在保存时以 `note-too-large` 失败,而不是在输入过程中。 +- 弹窗不预先校验 `maxNoteBytes`;超长描述在提交时以 `note-too-large` 失败,而不是在输入过程中。 +- `sessionFeedback.record` 只服务 live Session,否则回答 `session-not-found`;弹窗打开期间 Session 退役时,弹窗会报告该失败。 @@ -282,6 +354,24 @@ Session-log service; cold operations never construct a Session or Agent. Source: [`packages/feedback/message-feedback/src/index.ts`](../../packages/feedback/message-feedback/src/index.ts) + + +### `ctx.sessionFeedback` — `SessionFeedbackService` + +Host Remote through which a product surface records a Session-level remark. + +```ts cordis-catalog +/** + * Record one remark on a live Session. + * @param request - target Session plus the optional text and category. + * @returns the recorded postcondition, or `session-not-found` when no live + * Session carries the id. + */ +@Remote('record') record(request: SessionFeedbackRecordRequest): Promise +``` + +Source: [`packages/feedback/command-feedback/src/index.ts`](../../packages/feedback/command-feedback/src/index.ts) + ### `feedback/*` events diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 3d91c68dc6..0d7280d631 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -84,6 +84,7 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-files": "workspace:^", - "zod": "^4.4.3" + "zod": "^4.4.3", + "@deepseek-ai/dsh-command-feedback": "workspace:^" } } diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index bc93c25cca..fa0dae245e 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -9,6 +9,7 @@ import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' +import sessionFeedbackRemote from '@deepseek-ai/dsh-command-feedback/remote' import fileUploadsRemote from '@deepseek-ai/dsh-client-file-upload/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' @@ -26,6 +27,7 @@ export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-llm/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' +export type {} from '@deepseek-ai/dsh-command-feedback/remote' export type {} from '@deepseek-ai/dsh-client-file-upload/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' export type {} from '@deepseek-ai/dsh-subagent/remote' @@ -150,7 +152,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { try { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, - pluginInventoryRemote, messageFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, + pluginInventoryRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 589d5e92bd..7222a73c98 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -79,6 +79,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../feedback/command-feedback" } ] } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 346abe5347..a25fc337ac 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -315,8 +315,9 @@ - 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. + # The feedback surface: Like/Dislike in the assistant-message action + # strip, the feedback dialog behind Dislike and /feedback with its + # acknowledgement toast, over the messageFeedback and sessionFeedback Remotes. - id: ui-message-feedback name: '@deepseek-ai/dsh-client-ui-message-feedback' diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index c23797ba1f..2a62fbbd35 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -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/README.md -README.md: 6bb433b8411fa9db3d6de981a24895e3c7b674c4 -README.zh.md: c04292b86becba404e5dbb58ca924833ee88f58b +README.md: cf4b6aceb320d942d695fb1dce53c2e67e53a969 +README.zh.md: 1f895de009e82688ede9b3a83807bae9689ccb80 diff --git a/packages/client/README.md b/packages/client/README.md index 6bb433b841..cf4b6aceb3 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -71,7 +71,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | -| [`ui-message-feedback/`](ui-message-feedback/README.md) | Contributes per-message feedback controls to the assistant-message action strip | — | +| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind Dislike and `/feedback` | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — | | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index c04292b86b..1f895de009 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -71,7 +71,7 @@ kind: "package-group" | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页 | — | | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | -| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 向助手消息操作条贡献逐消息反馈控件 | — | +| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点踩与 `/feedback` 背后的反馈弹窗 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — | | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开 workspace 目录的会话头部分体按钮 | — | diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index dc82321424..4ebf705b73 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -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-commands/README.md -README.md: e1264d10bd8ff807d8f348339e0c055607579407 -README.zh.md: c5323149aea1e38ff09262db9bd2aacad7b284ea +README.md: c5f3c55c08a6df8e6c3f122d0c5e341721c2b41a +README.zh.md: f728c03e067f74ba5142464adc17531225034aad diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index e1264d10bd..c5f3c55c08 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -1,5 +1,5 @@ --- -description: "Client command API for the Web GUI: the / command source, three dispatch kinds, the per-session command directory, and popupSelect registration for business packages; for users and maintainers of slash commands." +description: "Client command API for the Web GUI: the / command source, three dispatch kinds, the per-session command directory, and popupSelect and action registration for business packages; for users and maintainers of slash commands." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Typing a `/` command in the composer opens the matching surface — a registered popup, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`, registering a popupSelect spec (`/model`, `/permission`) or decorating an existing host command with a picker while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is `popupSelect`, and everything else is `execute`. +Typing a `/` command in the composer opens the matching surface — a registered popup, a host command's input, or a direct execution — and a command line is never silently downgraded to a plain prompt. Business packages contribute command surfaces through `ctx.commandUi`: a popupSelect spec (`/model`, `/permission`) or an action (`/feedback`), registered as a command or decorating an existing host command while the host keeps its catalog row and argument claim. Space and Enter resolve the line against the session's directory: a host descriptor with `input` is `leadingInput`, a registered `CommandUiSpec` is its kind, and everything else is `execute`. ## Table of Contents diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index c5323149ae..f728c03e06 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI 的客户端命令 API:/ 命令 source、三类派发、会话级命令目录,以及面向业务包的 popupSelect 注册;供斜杠命令的用户与维护者阅读。" +description: "Web GUI 的客户端命令 API:/ 命令 source、三类派发、会话级命令目录,以及面向业务包的 popupSelect 与 action 注册;供斜杠命令的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:注册 popupSelect 贡献项(`/model`、`/permission`),或用选择器装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的是 `popupSelect`,其余全部是 `execute`。 +在 composer 中键入 `/` 命令会打开匹配的表面——已注册的弹窗、宿主命令的输入或直接执行——命令行绝不会被静默降级为普通提示词。业务包经 `ctx.commandUi` 贡献命令表面:popupSelect 贡献项(`/model`、`/permission`)或 action(`/feedback`),既可注册为命令,也可装饰既有宿主命令,宿主保留其目录行与参数声明。空格与回车对照会话目录解析命令行:带 `input` 的宿主描述符是 `leadingInput`,注册了 `CommandUiSpec` 的按其种类派发,其余全部是 `execute`。 ## 目录 diff --git a/packages/client/ui-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index fe479990f4..326f933d02 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/packages/client/ui-commands/src/client/contract.ts @@ -31,12 +31,30 @@ export interface SelectOption { * The shell component is owned by ui-commands; business never sees it. Both * callbacks receive the ClientSessionContext captured at popup open. */ -export type CommandUiSpec = { +export interface PopupSelectSpec { readonly kind: 'popupSelect' options(session: ClientSessionContext, signal: AbortSignal): Promise onSelect(option: SelectOption, session: ClientSessionContext): void | Promise } +/** + * Business registration for the action command kind: a bare invocation + * consumes the trigger token and runs one client-side callback (the Feedback + * row opens the feedback dialog). It submits nothing, so an + * attachment-carrying draft never refuses it. + */ +export interface ActionSpec { + readonly kind: 'action' + /** + * Run the action for one session. + * @param session - the ClientSessionContext captured at invocation. + */ + run(session: ClientSessionContext): void +} + +/** The UI behavior of a contribution or decoration. */ +export type CommandUiSpec = PopupSelectSpec | ActionSpec + /** * One client-owned command contribution: a slash-menu entry whose behavior * lives entirely on the client (no host descriptor). Merged with the host @@ -50,7 +68,7 @@ export interface CommandContribution { readonly description: () => string /** Capability filter, called with a fresh projection per candidate pass. */ available(session: ClientSessionContext): boolean - /** The command's UI behavior (this phase: popupSelect only). */ + /** The command's UI behavior. */ readonly ui: CommandUiSpec } @@ -68,7 +86,7 @@ export interface CommandDecoration { readonly name: string /** Capability filter, called with a fresh projection per bare invocation. */ available(session: ClientSessionContext): boolean - /** The bare-invocation UI (this phase: popupSelect only). */ + /** The bare-invocation UI. */ readonly ui: CommandUiSpec } diff --git a/packages/client/ui-commands/src/client/index.ts b/packages/client/ui-commands/src/client/index.ts index 26e8a32e09..0c1240ab76 100644 --- a/packages/client/ui-commands/src/client/index.ts +++ b/packages/client/ui-commands/src/client/index.ts @@ -27,7 +27,8 @@ export { filterOptions, PopupSelectController } from './popup.ts' export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx' export type { - CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, SelectConfirmation, SelectOption, + ActionSpec, CommandContribution, CommandDecoration, CommandUiContract, CommandUiSpec, PopupSelectSpec, + SelectConfirmation, SelectOption, } from './contract.ts' export type { CommandKey } from './locales.ts' diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index f14f13f0be..59f885bb6d 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -228,22 +228,22 @@ export class CommandUiRuntime extends Service implements CommandUiContract { return key !== undefined && command.description === en[key] ? this.t(key) : command.description } - /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ + /** Decision table, menu column: contribution/decorated-host → popup or action; host input → claim; host bare → detached execute. */ private dispatch(pick: InputTriggerPick): PickOutcome { const name = pick.candidate.name const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(pick.session)) { - this.openPopup(name, contribution.ui, pick.session, { via: 'menu', span: pick.span }) + this.invoke(name, contribution.ui, pick.session, { via: 'menu', span: pick.span }) return 'handled' } const desc = this.directory.resolve(pick.session.sessionId, name) if (desc === undefined) return undefined // snapshot swapped between menu and pick → miss - // A decoration replaces the HOST row's bare invocation with its popup; - // it decorates only a resolvable host command (checked above), never - // manufactures one, and never touches the argument claim below. + // A decoration replaces the HOST row's bare invocation with its popup or + // action; it decorates only a resolvable host command (checked above), + // never manufactures one, and never touches the argument claim below. const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(pick.session)) { - this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span }) + this.invoke(name, decoration.ui, pick.session, { via: 'menu', span: pick.span }) return 'handled' } if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) } @@ -258,7 +258,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract { private matchSpace(session: ClientSessionContext, token: string): PickOutcome { if (!token.startsWith('/')) return undefined const name = token.slice(1) - if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space + if (this.live.contributions.has(name)) return undefined // popup and action kinds never claim on space const desc = this.directory.resolve(session.sessionId, name) if (desc === undefined || desc.input === undefined) return undefined return { claim: this.leadingClaim(desc, session) } @@ -271,10 +271,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract { * args-tolerant. * * Envelope policy: an enter submission carrying attachments resolves only - * through a command declaring attachment acceptance. Every other command route — - * popup, non-accepting claim, bare detached execute — throws the refusal - * so the machine surfaces one composer notice and the draft and attachments - * stay in place; nothing executes and nothing is dropped. + * through a command declaring attachment acceptance. Every other submitting + * route — popup, non-accepting claim, bare detached execute — throws the + * refusal so the machine surfaces one composer notice and the draft and + * attachments stay in place; nothing executes and nothing is dropped. An + * action submits nothing and runs regardless. */ private async matchEnter( session: ClientSessionContext, @@ -295,8 +296,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const contribution = this.live.contributions.get(name) if (contribution !== undefined && contribution.available(session)) { if (!bare) return undefined - if (envelope.attachments > 0) refuseAttachments() - this.openPopup(name, contribution.ui, session, { via: 'enter', token }) + if (envelope.attachments > 0 && contribution.ui.kind !== 'action') refuseAttachments() + this.invoke(name, contribution.ui, session, { via: 'enter', token }) return 'handled' } await this.directory.ensureReady(session.sessionId, signal) @@ -307,8 +308,8 @@ export class CommandUiRuntime extends Service implements CommandUiContract { if (bare) { const decoration = this.live.decorations.get(name) if (decoration !== undefined && decoration.available(session)) { - if (envelope.attachments > 0) refuseAttachments() - this.openPopup(name, decoration.ui, session, { via: 'enter', token }) + if (envelope.attachments > 0 && decoration.ui.kind !== 'action') refuseAttachments() + this.invoke(name, decoration.ui, session, { via: 'enter', token }) return 'handled' } } @@ -323,13 +324,21 @@ export class CommandUiRuntime extends Service implements CommandUiContract { return 'handled' } - /** Open the session's popup for one contribution or decoration (menu pick / bare enter). */ - private openPopup( + /** + * Invoke one contribution or decoration (menu pick / bare enter): open the + * session's popup, or consume the token and run the action. + */ + private invoke( name: string, ui: CommandContribution['ui'], session: ClientSessionContext, segment: TokenSegment, ): void { + if (ui.kind === 'action') { + this.consumeVia(session.sessionId, segment) + ui.run(session) + return + } const actx = this.scopeFor(session.sessionId) if (actx === undefined) return this.popupFor(actx).open(name, ui, session, segment) diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index f387ebffc1..fd48118587 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -14,7 +14,7 @@ import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/cl import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -import type { CommandContribution, CommandDecoration, CommandUiSpec, SelectOption } from '../src/client/contract.ts' +import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' import { CommandUiRuntime } from '../src/client/service.ts' @@ -157,7 +157,7 @@ function menuPick(source: InputTriggerSource, name: string, session: ClientSessi return source.onPick(pick) } -const themeUi = (over: Partial = {}): CommandUiSpec => ({ +const themeUi = (over: Partial = {}): PopupSelectSpec => ({ kind: 'popupSelect', options: () => Promise.resolve([{ id: 'dark', label: 'Dark' }]), onSelect: () => undefined, @@ -384,6 +384,43 @@ describe('dispatch (menu column)', () => { expect(executeCalls).toEqual([]) }) + it('action decoration: a menu pick consumes the span and runs the callback without executing', async () => { + const { command, source, mint, warm, executeCalls } = await bench() + const scope = mint('s1') + const consumes: ConsumeTokenRequest[] = [] + scope.ctx.on('slash/input-consume-token', (r) => { + consumes.push(r) + return true + }) + const run = vi.fn() + command.decorate({ name: 'plan', available: () => true, ui: { kind: 'action', run } }) + await warm(proj('s1')) + expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled') + expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }]) + expect(run).toHaveBeenCalledWith(proj('s1')) + expect(executeCalls).toEqual([]) + expect(command.popupFor(scope.ctx).state.getSnapshot().open).toBe(false) + }) + + it('action decoration: a bare enter runs even with attachments; an argued line bypasses it', async () => { + const { command, source, mint, warm } = await bench() + const scope = mint('s1') + const consumes: ConsumeTokenRequest[] = [] + scope.ctx.on('slash/input-consume-token', (r) => { + consumes.push(r) + return true + }) + const run = vi.fn() + command.decorate({ name: 'plan', available: () => true, ui: { kind: 'action', run } }) + await warm(proj('s1')) + await expect(source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 1 })).resolves.toBe('handled') + expect(consumes).toEqual([{ guard: { kind: 'bare-token', token: '/plan' } }]) + expect(run).toHaveBeenCalledTimes(1) + // An argued line never consults the decoration. + await expect(source.matchEnter!(proj('s1'), '/plan later', new AbortController().signal, { attachments: 0 })).resolves.not.toBe('handled') + expect(run).toHaveBeenCalledTimes(1) + }) + it('host bare → consume-token span guard on the session scope + detached execute', async () => { const { source, mint, warm, executeCalls, executions } = await bench() const scope = mint('s1') diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index 6570ef5e3e..d7a5f947b4 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/README.i18n.yaml @@ -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-message-feedback/README.md -README.md: 171a812acc836eec243eb9f596270359c0a3aa9c -README.zh.md: e34eba9014779fc27bbd5a7ef071a086e8982f45 +README.md: 81a37bd8a62c49a17a8c70918ff3de1fd5fc4eec +README.zh.md: c7015c62bba808459c348b3fb85c7cae433830d6 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index 171a812acc..81a37bd8a6 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -1,5 +1,5 @@ --- -description: "Per-message feedback for the Web GUI: the Like/Dislike pair and optional note in the finalized assistant message's action row; for users and maintainers of the feedback experience." +description: "The Web feedback surface: the Like/Dislike pair in the finalized assistant message's action row, the feedback dialog behind Dislike and `/feedback`, and the acknowledgement toast; for users and maintainers of the feedback experience." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package adds per-message feedback to the Web GUI: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry of the finalized assistant message's action strip. It renders on the closing assistant message of each turn — earlier steps of a multi-step turn produce tool rows rather than a rateable body. One controller per Session backs every message control in that Session, so a single list read seeds the whole transcript. Ratings and notes are log-only Session events: they never enter model context. Deletion retracts the current item without erasing its earlier log entries. +This package is the Web GUI's feedback surface: the Like/Dislike pair in the finalized assistant message's action strip, the feedback dialog with its acknowledgement toast in the composer overlay, and a decoration that opens the dialog from a bare `/feedback`. Like records at once and shows the toast; Dislike opens the dialog, which collects a category and an optional description. One surface per Session backs every entry, so a single list read seeds the whole transcript and one dialog serves the Session and its messages. Ratings, categories, and notes are log-only Session events that never enter model context. ## Table of Contents @@ -25,11 +25,11 @@ This package adds per-message feedback to the Web GUI: a Like/Dislike pair plus ## Use this package -Mount this plugin alongside `ui-conversation`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch. Clicking the recorded rating retracts the feedback; switching sides carries the existing note forward. The note editor is a dialog popover anchored under its trigger, so the row keeps its single line whether the editor is open or closed. +Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch, and the Feedback row of the composer menu opens the dialog. A recorded rating shows the filled glyph and stays visible without hover. Like records immediately and the toast thanks the user for the feedback. Dislike opens the dialog: seven category chips and a detail box, both optional; Submit records a negative judgment carrying whatever was filled in, and the conversation log travels with every feedback event. Clicking the recorded rating retracts it. A bare `/feedback`, picked from the menu or typed and sent without text, opens the same dialog for the Session; `/feedback ` keeps the Host command path and its acknowledgement row. ### Failures -A rating or list-load failure shows inline in the row; a note-save failure shows inside the popover, which stays open so the draft can be corrected. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. +A rating or list-load failure shows inline in the row; a submission failure shows inside the dialog, which stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. ----- @@ -39,7 +39,9 @@ A rating or list-load failure shows inline in the row; a note-save failure shows
Implementation internals — click to expand -The package contributes the `feedback` entry (order 10) of `conversation.chat.assistant-actions`, declared by ui-conversation and rendered inside the finalized assistant message's IconActions row. One `MessageFeedbackController` 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. 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. Mutations serialize per Session, so a queued operation always compares against the committed version. +The package contributes the `feedback` entry (order 10) of `conversation.chat.assistant-actions`, declared by ui-conversation and rendered inside the finalized assistant message's IconActions row, and the `feedback-dialog` entry (order 2) of `conversation.input.overlay`, which renders the Modal and Toast primitives through body portals and centers the toast over the composer card it mounts inside. The `/feedback` decoration is an `action` registered through `ctx.commandUi.decorate`, so a menu pick or a bare Enter consumes the trigger token and opens the dialog while an argued line still reaches the Host command. + +Per Session, one `MessageFeedbackController` backs every message control and one `FeedbackDialogController` owns the dialog draft, the submission, and the toast sequence. The message controller reads `messageFeedback.list` once, deferred to the first hover or focus rather than fired on mount, and serializes mutations so each carries the version last observed; a `version-conflict` reply carries the authoritative item and reconciles the view without refetching. `toggle` reports the rating now committed, so the row acknowledges a recorded Like and not a retraction. The dialog controller submits by target: a message target puts a negative judgment with the dialog's note and category through the message controller, and the Session target records through `ctx.remote.sessionFeedback`. Success closes the draft and raises the toast; a late success from a superseded draft raises the toast without closing the new draft; a failure keeps the draft open with its code.
@@ -48,10 +50,12 @@ The package contributes the `feedback` entry (order 10) of `conversation.chat.as ## Further Exploration -Read these pages when the feedback surface is not enough. They move from the browser strip to the Session-log backend and the conversation shell. +Read these pages when the feedback surface is not enough. They move from the browser strip to the Session-log backends and the conversation shell. - [dsh-message-feedback](../../feedback/message-feedback/README.md) — the Session-log backend that owns per-item compare-and-set and persistence. -- [ui-conversation](../ui-conversation/README.md) — declares the assistant-actions strip and renders the action row. +- [dsh-command-feedback](../../feedback/command-feedback/README.md) — the `/feedback` command, the `sessionFeedback` Remote, and the category taxonomy. +- [ui-commands](../ui-commands/README.md) — the command decoration contract the `/feedback` row goes through. +- [ui-conversation](../ui-conversation/README.md) — declares the assistant-actions strip and the composer overlay. - [Client package map](../README.md) — adjacent browser UI packages. ----- @@ -59,7 +63,7 @@ Read these pages when the feedback surface is not enough. They move from the bro ## Model Experience -None, as ratings and notes are log-only events, not model input. Optional Session-log delivery uses request metadata rather than model context. +None, as ratings, categories, and notes are log-only events, not model input. Optional Session-log delivery uses request metadata rather than model context. #### KV Cache effect @@ -72,7 +76,8 @@ None; feedback mutations leave the model-visible history unchanged. These limits define the current feedback surface. They are current package constraints, not a general rating comparison or a task backlog. -- **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. +- **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 dialog does not pre-check the limit, so an oversized description fails on submit rather than while typing. +- **No note on a Like** — only the Dislike dialog collects a category and description; a Like records the bare judgment. - **No cross-tab push** — a second tab's rating becomes visible on reconnect or on the next conflict reply, not immediately; the controller does not consume feedback log events. - **Chat view only** — the trajectory and waterfall views render no feedback controls even though their assistant nodes carry the same `messageId`. @@ -86,4 +91,4 @@ None. -**Runtime invariant:** No companion is published. 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. +**Runtime invariant:** No companion is published. The plugin owns two slot registrations, one command decoration, and one per-session controller-pair map, all released by the plugin fiber's effect disposers. The lifecycle spec proves the registrations are withdrawn and every controller pair is dropped when the owning fiber is disposed, so no second authority exists to check at runtime. diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index e34eba9014..c7015c62bb 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web GUI 的逐消息反馈:已定稿助手消息动作行中的 Like/Dislike 对与可选备注;供反馈体验的用户与维护者阅读。" +description: "Web 反馈界面:已定稿助手消息动作行中的 Like/Dislike 对、点踩与 `/feedback` 背后的反馈弹窗,以及确认 toast;供反馈体验的用户与维护者阅读。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包为 Web GUI 增加逐消息反馈:一对 Like/Dislike 按钮加一个可选备注,作为已定稿助手消息动作条的 `feedback` 条目贡献。它渲染在每个轮次的收尾助手消息上——多步骤轮次中较早的步骤产出工具行而非可评分正文。每个 Session 一个控制器支撑该 Session 内所有消息的控件,因此一次列表读取即可填充整段对话。评分与备注是仅写日志的 Session 事件:它们绝不进入模型上下文。删除会撤回当前条目,但不会抹除早先的日志记录。 +本包是 Web GUI 的反馈界面:已定稿助手消息动作条中的 Like/Dislike 对、输入框浮层中的反馈弹窗及其确认 toast,以及让不带文本的 `/feedback` 打开弹窗的装饰。点赞立即记录并显示 toast;点踩打开弹窗,弹窗收集分类与可选描述。每个 Session 一个 surface 支撑所有条目,因此一次列表读取即可填充整段对话,一个弹窗同时服务 Session 与其消息。评分、分类与备注是仅写日志的 Session 事件,绝不进入模型上下文。 ## 目录 @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间。再次点击已记录的评分会撤回反馈;切换到另一侧会保留既有备注。备注编辑器是一个锚定在其触发按钮下方的对话框浮层,因此无论编辑器是否打开,该行都保持单行。 +与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单里的「反馈」行则打开弹窗。已记录的评分显示实心图标,不需要悬停也一直可见。点赞立即记录,toast 显示感谢反馈。点踩打开弹窗:七个分类标签和一个详情框,都可不填;提交会记录一条带上所填内容的差评,对话日志随每个反馈事件一起投递。再次点击已记录的评分会撤回它。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback ` 仍走宿主命令路径并显示确认行。 ### 失败 -评分或列表加载失败在行内展示;备注保存失败在浮层内展示,面板保持打开以便修正草稿。只有已定稿的消息能到达该槽位——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。 +评分或列表加载失败在行内展示;提交失败在弹窗内展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。 ----- @@ -39,7 +39,9 @@ kind: "package-reference"
实现细节——点击展开 -本包贡献 `conversation.chat.assistant-actions` 的 `feedback` 条目(order 10),由 ui-conversation 声明并渲染在已定稿助手消息的 IconActions 行内。每个 Session 一个 `MessageFeedbackController` 支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话;该读取延迟到首次 hover 或 focus 才发起,而非挂载时触发。变更经 `ctx.remote.messageFeedback` 提交,按条目的比较并交换由宿主负责。每次 `put` 与 `delete` 都携带本控制器最后观察到的 `version`;`version-conflict` 响应带回权威条目,因此竞争失败时直接用该响应本身对账,无需重新拉取。变更按 Session 串行,排队中的操作总是与已提交的版本比较。 +本包贡献 `conversation.chat.assistant-actions` 的 `feedback` 条目(order 10),由 ui-conversation 声明并渲染在已定稿助手消息的 IconActions 行内;同时贡献 `conversation.input.overlay` 的 `feedback-dialog` 条目(order 2),它通过 body portal 渲染 Modal 与 Toast 基元,并让 toast 以其所在的输入框卡片为中心。`/feedback` 装饰是经 `ctx.commandUi.decorate` 注册的 `action`,因此菜单选中或不带参数的回车会消费触发 token 并打开弹窗,而带参数的命令行仍到达宿主命令。 + +每个 Session 有一个 `MessageFeedbackController` 支撑所有消息控件,以及一个 `FeedbackDialogController` 拥有弹窗草稿、提交与 toast 序号。消息控制器只读取一次 `messageFeedback.list`,且延迟到首次 hover 或 focus 才发起,而非挂载时触发;变更串行执行,每次都携带最后观察到的版本,`version-conflict` 响应带回权威条目,据此对账视图而不重新拉取。`toggle` 会报告提交后的评分,因此该行只对记录成功的点赞做确认,撤回不做。弹窗控制器按目标提交:消息目标通过消息控制器 put 一条带弹窗备注与分类的差评,Session 目标通过 `ctx.remote.sessionFeedback` 记录。成功会关闭草稿并弹出 toast;被替换的旧草稿迟到的成功只弹 toast、不关闭新草稿;失败让草稿保持打开并带上失败码。
@@ -48,10 +50,12 @@ kind: "package-reference" ## 进一步探索 -当反馈面不够用时阅读以下页面。它们从浏览器条带进入 Session 日志后端与会话外壳。 +当反馈界面不够用时阅读以下页面。它们从浏览器条带进入 Session 日志后端与会话外壳。 - [dsh-message-feedback](../../feedback/message-feedback/README.zh.md)——拥有按条目比较并交换与持久化的 Session 日志后端。 -- [ui-conversation](../ui-conversation/README.zh.md)——声明助手动作条并渲染动作行。 +- [dsh-command-feedback](../../feedback/command-feedback/README.zh.md)——`/feedback` 命令、`sessionFeedback` Remote 与分类表。 +- [ui-commands](../ui-commands/README.zh.md)——`/feedback` 行所经过的命令装饰约定。 +- [ui-conversation](../ui-conversation/README.zh.md)——声明助手动作条与输入框浮层。 - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。 ----- @@ -59,7 +63,7 @@ kind: "package-reference" ## 模型体验 -无。评分与备注是仅写日志的事件,不是模型输入。可选的 Session 日志投递使用请求元数据,而非模型上下文。 +无。评分、分类与备注是仅写日志的事件,不是模型输入。可选的 Session 日志投递使用请求元数据,而非模型上下文。 #### KV Cache 影响 @@ -70,9 +74,10 @@ kind: "package-reference" -这些限制界定了当前反馈表面。它们是当前包约束,不是通用评分对比或任务积压。 +这些限制界定了当前反馈界面。它们是当前包约束,不是通用评分对比或任务积压。 -- **备注大小是宿主策略**——部署方配置 `maxNoteBytes`(Web bundle 中为 8192),超长备注由宿主以 `note-too-large` 拒绝。编辑器不预先校验该上限,因此超长备注在保存时才失败,而不是在输入过程中。 +- **备注大小是宿主策略**——部署方配置 `maxNoteBytes`(Web bundle 中为 8192),超长备注由宿主以 `note-too-large` 拒绝。弹窗不预先校验该上限,因此超长描述在提交时才失败,而不是在输入过程中。 +- **点赞不带备注**——只有点踩弹窗收集分类与描述;点赞只记录判断本身。 - **无跨标签页推送**——另一个标签页的评分要等到重连或下一次冲突响应才可见,不会立即出现;控制器不消费反馈日志事件。 - **仅限对话视图**——trajectory 与 waterfall 视图不渲染反馈控件,尽管它们的助手节点也带有相同的 `messageId`。 @@ -86,4 +91,4 @@ kind: "package-reference" -**运行时不变式:** 不发布伴生入口。插件持有一个 slot 注册和按 Session 的 controller map,两者由同一 effect disposer 释放;生命周期测试已直接观察该关系。 +**运行时不变式:** 不发布伴生入口。插件持有两个 slot 注册、一个命令装饰和一个按 Session 的控制器对 map,全部由插件 fiber 的 effect disposer 释放;生命周期测试已直接观察注册被撤销、每对控制器被丢弃,因此运行时没有第二个可核对的权威。 diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index b73978ed20..d5fd7aed7e 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", - "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", + "description": "The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind Dislike and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes", "version": "0.1.5-alpha.1", "publishConfig": { "access": "public" @@ -31,7 +31,8 @@ "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-renderer" + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-commands" ], "platform": "web" } @@ -63,7 +64,10 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-commands": "workspace:^", + "@deepseek-ai/dsh-command-feedback": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css new file mode 100644 index 0000000000..b337725492 --- /dev/null +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.module.css @@ -0,0 +1,93 @@ +/* The design's dialog is the shared Modal card at 640px; everything else + about the chrome (radius, header, close, footer) is the primitive's. */ +.dialog { + width: min(640px, 100%); +} + +.categories { + display: flex; + flex-wrap: wrap; + gap: 11px 10px; +} + +.chip { + height: 34px; + padding: 0 14px; + border: 1px solid var(--dsw-alias-border-l3); + border-radius: 17px; + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + line-height: 20px; + cursor: pointer; +} + +.chip:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chip:disabled { + cursor: default; + opacity: 0.4; +} + +.chipActive, +.chipActive:hover:not(:disabled) { + border-color: var(--dsw-alias-button-primary-fill); + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); +} + +.detail { + display: block; + width: 100%; + height: 148px; + box-sizing: border-box; + margin-top: 28px; + padding: 14px; + border: 1px solid var(--dsw-alias-border-l4); + border-radius: 12px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + line-height: 20px; + resize: none; +} + +.detail::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.detail:focus { + outline: none; + border-color: var(--dsw-alias-border-l3); +} + +.failure { + display: block; + margin-top: 8px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; +} + +.submit { + width: 100%; + height: 56px; + margin-top: 36px; + border-radius: 28px; + font-size: 15px; +} + +/* The design's ringed green check, not the warning tint the Toast icon seat defaults to. */ +.toastIcon { + display: grid; + place-items: center; + width: 18px; + height: 18px; + border: 1.5px solid var(--dsw-alias-state-success-primary); + border-radius: 50%; + color: var(--dsw-alias-state-success-primary); +} diff --git a/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx b/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx new file mode 100644 index 0000000000..0c8e52ca59 --- /dev/null +++ b/packages/client/ui-message-feedback/src/client/FeedbackDialog.tsx @@ -0,0 +1,110 @@ +/** + * The feedback dialog and its acknowledgement toast, rendered as one entry + * of `conversation.input.overlay` so each Session owns exactly one of each. + * The Modal and the Toast both portal to `document.body`; the overlay slot + * only supplies the per-session controller and the composer card the toast + * centers over. + * @module @deepseek-ai/dsh-client-ui-message-feedback/client/FeedbackDialog + */ + +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { Button, IconCheckOutline16, Modal, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { FeedbackCategory } from '@deepseek-ai/dsh-command-feedback/types' +import type { FeedbackDialogProps } from './slots.ts' +import css from './FeedbackDialog.module.css' + +/** + * The chips in presentation order. A client bundle may not import a Host + * package's values, so the taxonomy is restated as a complete record of the + * `FeedbackCategory` union: a missing or foreign id is a compile error. + */ +const CATEGORY_CHIPS = { + 'task-result': true, + 'instruction-following': true, + 'product-interaction': true, + 'service-stability': true, + 'resource-cost': true, + 'security-privacy-permission': true, + 'other': true, +} satisfies Record +const CATEGORIES = Object.keys(CATEGORY_CHIPS) as FeedbackCategory[] + +/** Failure codes with their own copy; every other code reads the generic line. */ +const FAILURE_COPY: Partial> = { + 'version-conflict': 'error.conflict', + 'note-too-large': 'error.noteTooLarge', +} + +/** + * Render one Session's feedback dialog and toast. + * @param props - the dialog hook, the draft verbs, and the locale seat. + * @returns the modal while a target is open, the toast while one is showing. + */ +export function FeedbackDialog({ useDialog, edit, submit, dismiss, dismissToast, t }: FeedbackDialogProps) { + const state = useDialog(s => s) + // The toast centers over the composer card this entry renders inside of. + const probeRef = useRef(null) + const [card, setCard] = useState(null) + useLayoutEffect(() => { + setCard(probeRef.current?.closest('[data-composer-card]') ?? null) + }, []) + const toast = state.toast + const onToastDone = useCallback(() => { dismissToast(toast) }, [dismissToast, toast]) + const failure = state.failure === null ? null : t(FAILURE_COPY[state.failure] ?? 'error.generic') + + return ( + <> + } + anchor={card} + onDone={onToastDone} + /> + )} + { void submit() }} + > + {state.submitting ? t('submitting') : t('submit')} + + )} + > +
+ {CATEGORIES.map(category => ( + + ))} +
+