mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(web-shell): add per-turn satisfied / not satisfied marks (#12174)
A reader had no way to tell Web Shell that one particular answer was satisfactory, and a host embedding it had nowhere to hook that signal: the existing answer-footer extension point renders in its own block above the action row, so it cannot put controls beside Copy. Add two mutually exclusive marks to the right of Copy on a completed turn's final answer, plus an opt-in `assistantFeedback` customization item that notifies a host about every transition. The host is a listener only: the mark lands first, and throwing, rejecting, or cancelling a dialog in the callback never changes it. The mark is deliberately local — kept in localStorage, not in the session record — so no daemon route, record type, or transcript projection is added and the wire stays untouched. It is keyed by the daemon's promptId, the only value that survives a reload, since a block id is a per-projection ordinal and a persisted replay stamps the prompt's block rather than the answer's. No telemetry: marking emits no UserFeedbackEvent and is not gated by the usage-statistics setting.
This commit is contained in:
1 parent
3307b89cda
commit
2d49eff011
18 files changed
+1700
-5
No files matched your search
@@ -0,0 +1,276 @@
|
||||
# Web Shell Assistant Answer Feedback
|
||||
|
||||
[English](web-shell-assistant-feedback.md) | [简体中文](web-shell-assistant-feedback.zh-CN.md)
|
||||
|
||||
Status: implemented in this change.
|
||||
|
||||
## Problem
|
||||
|
||||
A reader of a completed answer has no way to tell Web Shell that the answer was
|
||||
satisfactory or not. The CLI TUI already offers a feedback dialog, but it is
|
||||
asked about the session rather than the turn, and no other client can record
|
||||
per-turn sentiment about a specific answer.
|
||||
|
||||
Hosts embedding Web Shell also have nowhere to hook such a signal: the existing
|
||||
answer-footer extension point only renders host content into its own block above
|
||||
the action row, so a host cannot put controls next to the built-in Copy button.
|
||||
|
||||
## Goals
|
||||
|
||||
- Let the reader mark a completed assistant turn as satisfied or not satisfied,
|
||||
from the answer's own action row, directly right of Copy.
|
||||
- Keep the mark visible afterwards, so a reader can see what they chose.
|
||||
- Let a host learn about each mark, without letting the host become the source of
|
||||
truth for the UI state.
|
||||
- Leave every other client and the daemon wire untouched.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No server-side persistence. The mark is not written to the session record, and
|
||||
is not readable by another browser, device, or client.
|
||||
- No telemetry. Marking does not emit an `UserFeedbackEvent` or any other
|
||||
analytics event, and is not gated by the usage-statistics setting.
|
||||
- No prompt-id anchoring, no daemon route, and no change to the daemon's
|
||||
transcript projection or wire format.
|
||||
- No host-supplied initial state: a host cannot seed or override a mark.
|
||||
- No per-turn visibility control: the host switches the whole feature on or off.
|
||||
|
||||
## Design
|
||||
|
||||
### Placement
|
||||
|
||||
The marks render in the completed assistant turn's action row, immediately after
|
||||
the Copy button and before the Branch button, the turn sources, and the
|
||||
timestamp. They appear only where Copy already appears — a completed, non
|
||||
streaming answer that is the final answer of its turn, in an interactive
|
||||
transcript.
|
||||
|
||||
### Interaction
|
||||
|
||||
| Current mark | Click | Result | Host payload `rating` |
|
||||
| ------------- | ------------- | ----------------------------------------- | --------------------- |
|
||||
| none | Satisfied | Satisfied is lit | `up` |
|
||||
| Satisfied | Not satisfied | Satisfied goes dark, Not satisfied lights | `down` |
|
||||
| Not satisfied | Not satisfied | dark again, unmarked | `null` |
|
||||
|
||||
The two marks are mutually exclusive. Every transition notifies the host once,
|
||||
including the transition to unmarked. The host is a listener: the mark lands
|
||||
before the notification runs, and nothing the host does in the callback — throw,
|
||||
reject, open a dialog the reader never submits — changes the mark.
|
||||
|
||||
A marked turn is coloured by direction — blue for satisfied, red for not
|
||||
satisfied — on a glyph sized to sit evenly beside the built-in Copy and Branch
|
||||
icons. The colour survives hover and focus, because the row only appears on
|
||||
hover; a mark that greyed out under the pointer would never be readable.
|
||||
|
||||
The row keeps its existing hover behaviour: marking does not pin it open. That
|
||||
needs one explicit step, since a pointer click leaves the button focused and the
|
||||
row's `:focus-within` rule would otherwise hold it open after the pointer
|
||||
leaves. Pointer clicks release focus; keyboard activation keeps it, so the row
|
||||
stays reachable without a mouse.
|
||||
|
||||
### State and storage
|
||||
|
||||
Web Shell owns the mark. State lives in the message list rather than in a message
|
||||
row, because the transcript virtualizes and unmounts rows that scroll out of
|
||||
view.
|
||||
|
||||
- Storage key: `qwen-web-shell-assistant-feedback`, one versioned JSON payload
|
||||
`{ v: 1, <sessionId>: { <promptId>: 'up' | 'down' } }`.
|
||||
- The mark is keyed by the turn's admitted prompt id, not by a transcript block
|
||||
id. See "Turn identity" below for why that is the only value that survives a
|
||||
reload, and what happens when a turn has none.
|
||||
- At most `MAX_ASSISTANT_FEEDBACK_SESSIONS` (10) sessions are kept. The oldest is
|
||||
dropped first, and editing a session moves it to the newest position so a
|
||||
session in active use is not aged out by an untouched neighbour.
|
||||
- Clearing the last mark of a session removes that session's entry.
|
||||
|
||||
Storage is best effort. A private or embedded context can refuse `localStorage`
|
||||
entirely, and a hand-edited or future payload may not parse: writing never
|
||||
throws, and reading degrades to "nothing marked" rather than guessing. In all
|
||||
failure modes the click still lands and the host is still notified; only the
|
||||
memory across a reload is lost.
|
||||
|
||||
### Turn identity
|
||||
|
||||
A block id is not an identity: the transcript reducer mints ids as
|
||||
`<kind>-<ordinal>` from a counter that starts at 1 per projection, so a block
|
||||
id depends on how the transcript was loaded rather than on what it contains.
|
||||
The mark is therefore keyed by the daemon's `promptId` — the uuid the daemon
|
||||
mints at prompt admission — which the daemon also persists on the turn's
|
||||
`turn_result` record and addresses turns by (`GET /session/:id/turns/:promptId`).
|
||||
|
||||
Where that value sits on the blocks differs between the two ways a transcript
|
||||
arrives, which was measured end to end rather than assumed:
|
||||
|
||||
- **Live**: the assistant blocks carry it, and it equals the id returned when the
|
||||
prompt was admitted.
|
||||
- **Replay** (opening a session, reloading, paginating — the daemon's
|
||||
`historyPageSize` path): the assistant blocks carry **no** `promptId`; only
|
||||
`user_message_chunk` does, and it equals the turn's persisted record.
|
||||
- A reload served from the daemon's in-memory snapshot instead of a persisted
|
||||
replay carries it on the assistant blocks again, so which shape arrives is not
|
||||
fixed.
|
||||
|
||||
The client therefore takes `answer.promptId ?? headUserMessage.promptId`: both
|
||||
sources hold the same value when either is present, and a turn with neither is
|
||||
not offered the marks at all. A second consequence is that the mark is not
|
||||
carried across a fork: forks deliberately exclude `turn_result` records so a new
|
||||
session does not inherit the source's prompt identities.
|
||||
|
||||
### Host API
|
||||
|
||||
```ts
|
||||
customization.assistantFeedback?: {
|
||||
enabled?: boolean; // `false` hides the marks again
|
||||
onRate?: (info: {
|
||||
rating: 'up' | 'down' | null; // null when a mark was cleared
|
||||
previousRating?: 'up' | 'down';
|
||||
sessionId?: string; // which conversation
|
||||
promptId: string; // which turn inside it
|
||||
userMessage: {
|
||||
text: string; // prompt tail, or what it carried
|
||||
timestamp?: number; // prompt wall-clock ms
|
||||
};
|
||||
}) => void;
|
||||
};
|
||||
```
|
||||
|
||||
`sessionId` locates the conversation and `promptId` locates the turn inside it —
|
||||
the same pair the daemon's own turn routes take; neither alone is enough.
|
||||
|
||||
`userMessage.text` is the prompt's most recent 100 characters, trimmed, because
|
||||
the tail is what distinguishes one prompt from another in a list of marks. When
|
||||
the prompt carried no text at all, the text is instead what it carried: `[图片]`
|
||||
when it had images, `[附件]` when it had attachments, and both joined by a space
|
||||
when it had each. The placeholders are literal and do not follow the UI
|
||||
language, because a host consumes them as data. A shell turn's prompt is its
|
||||
command, so `text` carries the command for those turns.
|
||||
|
||||
A host passes this as the `assistantFeedback` prop on the embedded `WebShell`,
|
||||
which forwards it into the customization context. Passing the object shows the
|
||||
marks; omitting it leaves the answer footer exactly as it is today, which is what
|
||||
keeps existing hosts unchanged. The payload types are exported from the package
|
||||
entry so a host can name them. This repository's own standalone app does not opt
|
||||
in, so the marks appear only where a host passes the option — deliberately, since
|
||||
the standalone app has no host to notify.
|
||||
|
||||
The marks are offered only in an interactive transcript that has a session id, so
|
||||
the read-only and embedded transcript renderings never show them — as does the
|
||||
frozen historical viewport, which drops its session id and therefore hides the
|
||||
marks on answers that still show Copy.
|
||||
|
||||
## Decisions and rationale
|
||||
|
||||
- **Built into the answer footer rather than a host-rendered footer.** The
|
||||
existing `renderAssistantTurnFooter` extension point renders above the action
|
||||
row, in its own block; it cannot place controls beside Copy, and it would force
|
||||
every host to re-implement lighting, exclusivity, and hover behaviour. The
|
||||
built-in path keeps one implementation and lets a host opt in with one object.
|
||||
- **The host is notified, never consulted.** A host that could reject or revert a
|
||||
mark would make the icon's state depend on a network round trip, and would put
|
||||
the record of what the reader chose outside the client. Notification-only keeps
|
||||
a click instant and its outcome final.
|
||||
- **Local storage rather than a session record.** Per-turn feedback persisted
|
||||
server-side would need a daemon write route, a new record type in a session
|
||||
file every client reads, and a read-back projection — a scope far beyond the
|
||||
feature, and one that reopens questions about user data and telemetry gating.
|
||||
A deliberately local mark buys the visible behaviour now and leaves the
|
||||
upgrade path open.
|
||||
- **No reuse of the TUI's `UserFeedbackEvent`.** That event is session-scoped,
|
||||
three-valued (`bad`/`fine`/`good`), has no "cleared" state, and its writer also
|
||||
emits telemetry. Reusing it would bind a UI toggle to an analytics sink and
|
||||
still not express a per-turn mark.
|
||||
|
||||
## Constraints
|
||||
|
||||
- The mark is keyed by session and turn, so it requires both. A transcript
|
||||
without a session id is not offered the marks rather than accepting clicks that
|
||||
could not be stored.
|
||||
- Two tabs, or two panes, do not synchronize live, and a write replaces the whole
|
||||
stored payload: each renders from the storage it read when it mounted, and a
|
||||
mark made in one instance can be dropped by a write from another.
|
||||
- A session that is deleted, rewound, or forked leaves its marks behind until the
|
||||
ten-session cap ages them out. They are never shown for a different session.
|
||||
|
||||
## Risks
|
||||
|
||||
- If a host also persists the marks on its own side, the two records can
|
||||
disagree — for example after the host deletes or migrates its copy. The client
|
||||
mark is presentation state and never reads the host's.
|
||||
- A cleared mark deletes the stored entry, so nothing preserves the history of
|
||||
a reader changing their mind.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `packages/web-shell/client/adapters/messageTypes.ts` | Declares `promptId` on rendered messages. |
|
||||
| `packages/web-shell/client/adapters/transcriptToMessages.ts` | Pass that copies the id from the contributing blocks onto the messages built from them. |
|
||||
| `packages/web-shell/client/utils/assistantFeedback.ts` | Store shape, pruning, tolerant read, best-effort write, the offer predicate, the prompt summary, and the host notification helper. |
|
||||
| `packages/web-shell/client/hooks/useAssistantFeedback.ts` | Owns the marks for one session and persists them. |
|
||||
| `packages/web-shell/client/components/messages/AssistantMessage.tsx` | Renders the two marks in the answer action row. |
|
||||
| `packages/web-shell/client/components/messages/AssistantMessage.module.css` | Direction colour for the marked icon, and the glyph sizing that matches its neighbours. |
|
||||
| `packages/web-shell/client/components/MessageItem.tsx` | Binds a row to its turn and prompt id. |
|
||||
| `packages/web-shell/client/components/MessageList.tsx` | Hosts the state, resolves each turn's prompt id, and builds the host payload. |
|
||||
| `packages/web-shell/client/customization.tsx` | `assistantFeedback` option and its payload types. |
|
||||
| `packages/web-shell/client/i18n.tsx` | `assistant.satisfied` and `assistant.dissatisfied`. |
|
||||
| `packages/web-shell/client/index.tsx` | Exports the payload types from the package entry. |
|
||||
| `packages/web-shell/client/App.tsx` | Forwards the host's option into the customization context. |
|
||||
|
||||
## Verification
|
||||
|
||||
Unit tests:
|
||||
|
||||
- `utils/assistantFeedback.test.ts`: round-trip, tolerant read (non-JSON, unknown
|
||||
rating, wrong version, throwing `localStorage` with a positive control), write
|
||||
that never throws, pruning and re-ordering, mark/switch/clear, the offer
|
||||
predicate's five cases, the prompt summary (tail length, trimming, the
|
||||
attachment and image placeholders, an unknown prompt), and the notification
|
||||
helper's throw containment.
|
||||
- `adapters/transcriptToMessages.test.ts`: the prompt-id pass stamps the messages
|
||||
built from stamped blocks, and invents no id for a block that has none.
|
||||
- `hooks/useAssistantFeedback.test.tsx`: marks immediately without a host
|
||||
handler, notifies with `previousRating`, clears on a repeat click, restores the
|
||||
mark after a remount, and keeps marks per session.
|
||||
- `components/messages/AssistantMessage.test.tsx`: no marks unless opted in,
|
||||
`aria-pressed` projection, the per-direction colour class, pointer-versus-
|
||||
keyboard focus handling, click-to-notify payloads, and the localized titles.
|
||||
|
||||
The live-versus-replay behaviour of `promptId` in "Turn identity" was measured
|
||||
end to end against a real daemon and a real browser, not inferred from the
|
||||
reducer: the same turn's assistant block carries the admitted id while streaming
|
||||
and no id after a persisted replay, while the prompt's own block carries the
|
||||
persisted value in both cases. That measurement is why the key falls back to the
|
||||
prompt's block instead of the answer's.
|
||||
|
||||
Gates: `prettier`, `eslint --max-warnings 0`, `tsc --noEmit`, and the full
|
||||
`packages/web-shell` suite all pass.
|
||||
|
||||
Manual: the visual result is checked by the owner on the running dev page. The
|
||||
strings introduced are `Satisfied` / `Not satisfied` in English and `满意` /
|
||||
`不满意` in Chinese, used as both tooltip and accessible name.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- With the option passed and a session id present, the marks appear on the final,
|
||||
completed, non-streaming answer of an interactive turn and on no other row.
|
||||
- Clicking a mark lights it and notifies the host once, reporting `previousRating`
|
||||
as it was before the click; clicking the lit mark clears it and notifies with
|
||||
`rating: null`.
|
||||
- A mark survives a reload of the same session and is never shown for a different
|
||||
session.
|
||||
- Omitting the option, or rendering a read-only or document transcript, shows no
|
||||
marks and changes nothing else in the footer.
|
||||
|
||||
## Follow-up work
|
||||
|
||||
- Server-side persistence is still not implemented: the mark lives in this
|
||||
browser. Doing it later means writing a record through a new daemon route and
|
||||
reading it back through a projection — the key (`sessionId` + `promptId`) and
|
||||
the payload in this document already carry what such a record needs.
|
||||
- The TUI's feedback dialog numbers its options `GOOD: 1, BAD: 2, FINE: 3` while
|
||||
the telemetry enum is `BAD = 1, FINE = 2, GOOD = 3`, and the rating is cast
|
||||
across without remapping, so the recorded rating is rotated. That is a
|
||||
pre-existing defect on a telemetry path, independent of this feature, and is
|
||||
deliberately not changed here.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Web Shell 助手回答的满意/不满意标记
|
||||
|
||||
[English](web-shell-assistant-feedback.md) | [简体中文](web-shell-assistant-feedback.zh-CN.md)
|
||||
|
||||
状态:本次改动已实现。
|
||||
|
||||
## 问题
|
||||
|
||||
读完一条回答后,读者没有办法告诉 Web Shell 这条回答是否令人满意。CLI 的 TUI 已有
|
||||
一个反馈弹窗,但它问的是整个会话、而不是某一轮,且任何其他客户端都无法记录"针对某一条
|
||||
具体回答"的态度。
|
||||
|
||||
嵌入 Web Shell 的宿主也没有地方挂这个信号:既有的回答底部扩展点只能把宿主内容渲染到
|
||||
操作行**上方**的独立块里,宿主没法把控件放到内置"复制"按钮旁边。
|
||||
|
||||
## 目标
|
||||
|
||||
- 让读者能在回答自己的操作行里、紧挨复制的右侧,把这一轮标记为满意或不满意。
|
||||
- 标记之后一直可见,读者能看见自己选了什么。
|
||||
- 让宿主能得知每一次标记,同时不让宿主成为界面状态的来源。
|
||||
- 其他所有客户端与 daemon 协议零改动。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不做服务端持久化。标记不写入会话记录,别的浏览器、设备或客户端读不到。
|
||||
- 不发遥测。标记不会发出 `UserFeedbackEvent` 或任何分析事件,也不受使用统计开关约束。
|
||||
- 不引入 `promptId` 锚点、不新增 daemon 路由、不改 daemon 的 transcript 投影与线格式。
|
||||
- 不接受宿主传入初始状态:宿主无法预置或覆盖标记。
|
||||
- 不做逐轮可见性控制:宿主只能整体开或关。
|
||||
|
||||
## 设计
|
||||
|
||||
### 位置
|
||||
|
||||
标记渲染在"已完成助手轮次"的操作行里,紧跟复制按钮之后,位于分叉按钮、来源与时间戳
|
||||
之前。它们出现的位置与复制按钮完全相同——已完成、不在流式中、且是该轮最终回答的那条,
|
||||
且只在可交互的 transcript 中。
|
||||
|
||||
### 交互
|
||||
|
||||
| 当前标记 | 点击 | 结果 | 回调里的 `rating` |
|
||||
| -------- | ------ | ------------------ | ----------------- |
|
||||
| 无 | 满意 | 满意亮 | `up` |
|
||||
| 满意 | 不满意 | 满意熄灭、不满意亮 | `down` |
|
||||
| 不满意 | 不满意 | 熄灭,回到未评 | `null` |
|
||||
|
||||
两个标记互斥。每一次变化都回调宿主一次,**包括回到未评**这一次。宿主是"监听者":标记
|
||||
先落到界面上,回调随后执行,宿主在回调里做什么——抛错、拒绝、弹出一个读者最终没提交的
|
||||
对话框——都不改变这个标记。
|
||||
|
||||
被标记的那一轮按方向着色——满意为蓝色,不满意为红色——图标尺寸调成与内置的复制、分叉
|
||||
同一视觉重量。颜色在 hover 与键盘聚焦下都保持:这行只在 hover 时出现,若鼠标一停上去
|
||||
颜色就被盖回灰色,方向色实际上永远读不到。
|
||||
|
||||
操作行保持原有的 hover 行为:标记不会让它常显。这一点需要额外一步——指针点击会给按钮
|
||||
留下焦点,而该行的 `:focus-within` 规则会因此在指针移开后仍把它撑开。指针点击会主动释放
|
||||
焦点;键盘激活则保留,这样不用鼠标也能继续操作这一行。
|
||||
|
||||
### 状态与存储
|
||||
|
||||
标记归 Web Shell 所有。状态放在消息列表**里**、而不是每一行里,因为 transcript 会虚拟滚动,
|
||||
滚出视野的行会被卸载。
|
||||
|
||||
- 存储键:`qwen-web-shell-assistant-feedback`,单条带版本的 JSON:
|
||||
`{ v: 1, <sessionId>: { <promptId>: 'up' | 'down' } }`。
|
||||
- 键用的是该轮**准入时的 prompt id**,不是 transcript 块 id。原因与"拿不到时怎么办"见
|
||||
下面的"轮次身份"。
|
||||
- 最多保留 `MAX_ASSISTANT_FEEDBACK_SESSIONS`(10)个会话,最先丢弃最旧的;编辑某个会话
|
||||
会把它移到最新位置,避免"正在使用的会话被邻居挤掉"。
|
||||
- 清掉某会话的最后一个标记时,该会话的条目一并删除。
|
||||
|
||||
存储是尽力而为。隐私模式或嵌入环境可能完全拒绝 `localStorage`,手改过或未来版本的载荷
|
||||
也可能解析不出来:写入永不抛错,读取在解析不了时退化为"没有任何标记",而不是猜。所有
|
||||
失败模式下,点击照常生效、宿主照常收到回调,丢掉的只是"刷新之后的记忆"。
|
||||
|
||||
### 轮次身份
|
||||
|
||||
块 id 不是身份:transcript reducer 按 `<kind>-<序号>` 发号,序号是每次投影从 1 开始的
|
||||
计数器,所以块 id 取决于"这份记录是怎么加载的",而不是它承载了什么。因此标记以 daemon 的
|
||||
`promptId` 为键——那是 daemon 在 prompt 准入时铸的 uuid,同时会被持久化到该轮的
|
||||
`turn_result` 记录里,也是 daemon 寻址一轮用的值(`GET /session/:id/turns/:promptId`)。
|
||||
|
||||
这个值落在哪些块上,取决于 transcript 走哪条路来——这两条路是**端到端实测**过的,不是推断:
|
||||
|
||||
- **live**:assistant 块带它,值等于 prompt 准入时返回的 id。
|
||||
- **重放**(打开会话、刷新、翻页——daemon 的 `historyPageSize` 路径):assistant 块**完全
|
||||
没有** `promptId`,只有 `user_message_chunk` 带,且它等于该轮落盘的记录。
|
||||
- 若刷新命中的是 daemon 的内存快照而非持久化重放,assistant 块又会重新带上它——所以到底
|
||||
来哪一种并不固定。
|
||||
|
||||
客户端因此取 `answer.promptId ?? headUserMessage.promptId`:只要有一侧存在,两侧就是同一个
|
||||
值;两侧都没有的轮次干脆不提供标记。另一个推论是**标记不随分叉携带**:分叉会刻意排除
|
||||
`turn_result` 记录,避免新会话继承来源的 prompt 身份。
|
||||
|
||||
### 宿主 API
|
||||
|
||||
```ts
|
||||
customization.assistantFeedback?: {
|
||||
enabled?: boolean; // 置 false 可再次隐藏标记
|
||||
onRate?: (info: {
|
||||
rating: 'up' | 'down' | null; // 取消标记时为 null
|
||||
previousRating?: 'up' | 'down';
|
||||
sessionId?: string; // 哪一次会话
|
||||
promptId: string; // 会话里的哪一轮
|
||||
userMessage: {
|
||||
text: string; // prompt 的尾部,或它携带了什么
|
||||
timestamp?: number; // prompt 的时间(epoch 毫秒)
|
||||
};
|
||||
}) => void;
|
||||
};
|
||||
```
|
||||
|
||||
`sessionId` 定位会话、`promptId` 定位会话内的一轮——与 daemon 自己的轮次路由所需的是同一
|
||||
对;只给其中一个都不够。
|
||||
|
||||
`userMessage.text` 是该 prompt **最新的 100 个字符**(先 trim),因为在一份标记列表里,
|
||||
区分两条 prompt 靠的是尾部。当这条 prompt 完全没有文字时,改为报告它携带了什么:有图片
|
||||
就是 `[图片]`,有附件就是 `[附件]`,两者都有则用空格连接。这两个占位符是字面量、**不随
|
||||
界面语言变化**,因为宿主是把它当数据消费的。shell 轮的 prompt 就是它的命令,因此这些轮次
|
||||
的 `text` 是那条命令。
|
||||
|
||||
宿主把它作为 `assistantFeedback` prop 传给嵌入的 `WebShell`,由后者转发进 customization
|
||||
上下文。传入这个对象即显示标记;不传则回答底部与今天完全一致,这正是老宿主零变化的原因。
|
||||
载荷类型已从包的入口导出,宿主可以给它们命名。本仓库自己的独立应用**不**开启它,所以标记
|
||||
只在宿主传入配置项时出现——这是刻意的,因为独立应用没有宿主可通知。
|
||||
|
||||
只有在**有会话 id 的可交互 transcript** 中才会提供标记,因此只读与嵌入的 transcript 渲染
|
||||
永远不显示它们——冻结的历史视口也一样,它会丢掉会话 id,于是在"复制"仍在的回答上不显示
|
||||
标记。
|
||||
|
||||
## 决策与理由
|
||||
|
||||
- **内建在回答操作行,而不是交给宿主渲染。** 既有的 `renderAssistantTurnFooter` 渲染在
|
||||
操作行**上方**的独立块里,无法把控件放到复制旁边;而且会让每个宿主各自重做点亮、互斥、
|
||||
hover 这些行为。内建路径只有一份实现,宿主用一个对象即可开启。
|
||||
- **宿主只被通知,不被询问。** 若宿主能否决或回滚标记,图标状态就会依赖一次网络往返,
|
||||
也会把"读者选了什么"的记录推到客户端之外。只通知,才能让点击即时且终局。
|
||||
- **用本地存储而不是会话记录。** 服务端持久化需要新增 daemon 写入路由、在所有客户端都读
|
||||
的会话文件里新增记录类型、再加一条读回投影——远超本功能的量级,并且会重新打开"用户数据"
|
||||
与"遥测开关口径"的问题。刻意做成局部的标记,先拿到可见行为,同时把升级路径留着。
|
||||
- **不复用 TUI 的 `UserFeedbackEvent`。** 那个事件是会话级、三值(`bad`/`fine`/`good`)、
|
||||
没有"已取消"状态,且它的写入函数同时向遥测出口发送。复用它会把一个界面开关绑到分析出口上,
|
||||
而且仍然表达不了"每轮一个标记"。
|
||||
|
||||
## 约束
|
||||
|
||||
- 标记以会话 + 轮次为键,两者都必须有。没有会话 id 的 transcript 不提供标记,而不是接受
|
||||
一次存不下来的点击。
|
||||
- 两个标签页、或分屏的两个 pane,不会实时同步,而且每次写入都会替换整份存储:各自按挂载
|
||||
时读到的存储渲染,一个实例里的标记可能被另一个实例的写入抹掉。
|
||||
- 会话被删除、回退或分叉后,它的标记会留到被"10 个会话"上限挤掉为止,且绝不会显示在
|
||||
别的会话里。
|
||||
|
||||
## 风险
|
||||
|
||||
- 若宿主自己也持久化了一份标记,两份记录可能不一致——例如宿主删掉或迁移了自己的那份。
|
||||
客户端这份是展示状态,永远不读宿主那份。
|
||||
- 取消标记会删除存储条目,因此不保留"读者改过主意"的历史。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `packages/web-shell/client/adapters/messageTypes.ts` | 声明渲染用消息上的 `promptId` 字段。 |
|
||||
| `packages/web-shell/client/adapters/transcriptToMessages.ts` | 一趟后处理:把贡献块的 id 复制到由它们构建出的消息上。 |
|
||||
| `packages/web-shell/client/utils/assistantFeedback.ts` | 存储形态、裁剪、容错读取、尽力写入、是否提供标记的判定、prompt 摘要、宿主通知封装。 |
|
||||
| `packages/web-shell/client/hooks/useAssistantFeedback.ts` | 持有一个会话的标记并持久化。 |
|
||||
| `packages/web-shell/client/components/messages/AssistantMessage.tsx` | 在回答操作行渲染两个标记。 |
|
||||
| `packages/web-shell/client/components/messages/AssistantMessage.module.css` | 标记图标的按方向着色,以及与相邻图标对齐的字形尺寸。 |
|
||||
| `packages/web-shell/client/components/MessageItem.tsx` | 把某一行绑定到它的轮次与 prompt id。 |
|
||||
| `packages/web-shell/client/components/MessageList.tsx` | 承载状态、解析每轮的 prompt id、组装宿主载荷。 |
|
||||
| `packages/web-shell/client/customization.tsx` | `assistantFeedback` 配置项及其载荷类型。 |
|
||||
| `packages/web-shell/client/i18n.tsx` | `assistant.satisfied` 与 `assistant.dissatisfied`。 |
|
||||
| `packages/web-shell/client/index.tsx` | 从包的入口导出载荷类型。 |
|
||||
| `packages/web-shell/client/App.tsx` | 把宿主的配置项转发进 customization 上下文。 |
|
||||
|
||||
## 验证
|
||||
|
||||
单元测试:
|
||||
|
||||
- `utils/assistantFeedback.test.ts`:往返读写、容错读取(非 JSON、未知取值、版本不符、
|
||||
`localStorage` 抛错且带正向控制)、写入永不抛错、裁剪与重排、标记/切换/取消、判定函数的
|
||||
五个分支、prompt 摘要(尾部长度、trim、附件与图片占位符、未知 prompt)、通知封装的抛错隔离。
|
||||
- `adapters/transcriptToMessages.test.ts`:prompt id 后处理会把带戳块上的值盖到消息上,
|
||||
且不会给没有戳的块编造一个。
|
||||
- `hooks/useAssistantFeedback.test.tsx`:没有宿主动调时标记即刻生效、回调带
|
||||
`previousRating`、重复点击取消、重新挂载后标记恢复、标记按会话隔离。
|
||||
- `components/messages/AssistantMessage.test.tsx`:未开启时不渲染、`aria-pressed` 投影、
|
||||
按方向的着色类名、指针点击与键盘激活的焦点差异、点击回调载荷、中英标题。
|
||||
|
||||
"轮次身份"一节里 live 与重放的差异,是**对着真实 daemon 与真实浏览器端到端实测**出来的,
|
||||
不是从 reducer 推的:同一轮的 assistant 块在流式时带准入 id,走持久化重放后则不带;而
|
||||
prompt 自己的块在两种情况下都带落盘值。正因如此,键才回退到 prompt 的块而不是回答的块。
|
||||
|
||||
门禁:`prettier`、`eslint --max-warnings 0`、`tsc --noEmit`,以及 `packages/web-shell`
|
||||
全量套件全部通过。
|
||||
|
||||
人工:视觉效果由使用者本人在运行中的开发页面确认。新增文案为英文
|
||||
`Satisfied` / `Not satisfied`,中文 `满意` / `不满意`,同时用作 tooltip 与无障碍名称。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 传入配置项且存在会话 id 时,标记只出现在可交互轮次里那条最终、已完成、不在流式中的回答上,
|
||||
不出现在任何其他行。
|
||||
- 点击标记会点亮它并回调宿主一次,`previousRating` 是点击之前的值;再次点击点亮的标记会取消
|
||||
它,并以 `rating: null` 回调。
|
||||
- 同一会话刷新后标记仍在,且绝不会显示在别的会话里。
|
||||
- 不传配置项,或渲染只读 / document transcript,都不显示标记,回答底部的其他部分不变。
|
||||
|
||||
## 后续事项
|
||||
|
||||
- 服务端持久化仍未实现:标记只存在这个浏览器里。将来要做,需要新增一条 daemon 路由写入、
|
||||
再通过投影读回——键(`sessionId` + `promptId`)与本文档里的载荷已经带齐了这样的记录所需
|
||||
的内容。
|
||||
- TUI 的反馈弹窗把选项编号为 `GOOD: 1, BAD: 2, FINE: 3`,而遥测枚举是
|
||||
`BAD = 1, FINE = 2, GOOD = 3`,中间是裸强转、没有重映射,因此记录下来的 rating 是错位
|
||||
的。这是遥测路径上一个既有缺陷,与本功能无关,本次刻意不予改动。
|
||||
@@ -402,6 +402,7 @@ import {
|
||||
} from './themeContext';
|
||||
import {
|
||||
WebShellCustomizationProvider,
|
||||
type WebShellAssistantFeedbackOptions,
|
||||
type WebShellComposerApi,
|
||||
type WebShellComposerInput,
|
||||
type WebShellMarkdownCustomization,
|
||||
@@ -1369,6 +1370,11 @@ export interface WebShellProps {
|
||||
onComposerTagClick?: ComposerTagClickHandler;
|
||||
/** Custom renderer displayed after the final assistant message of each turn. */
|
||||
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
|
||||
/**
|
||||
* Satisfied / not-satisfied marks on each completed assistant turn. Omit the
|
||||
* object to leave the answer footer unchanged.
|
||||
*/
|
||||
assistantFeedback?: WebShellAssistantFeedbackOptions;
|
||||
getAssistantSourcesIcon?: WebShellSourceIconResolver;
|
||||
sourceReferences?: readonly WebShellSourceReference[];
|
||||
/** Custom renderer inserted before the built-in chat composer toolbar controls. */
|
||||
@@ -3059,6 +3065,7 @@ export function App({
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
renderAssistantTurnFooter,
|
||||
assistantFeedback,
|
||||
getAssistantSourcesIcon,
|
||||
sourceReferences,
|
||||
renderComposerToolbarStart,
|
||||
@@ -3330,6 +3337,7 @@ export function App({
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
renderAssistantTurnFooter,
|
||||
assistantFeedback,
|
||||
getAssistantSourcesIcon,
|
||||
sourceReferences,
|
||||
renderComposerToolbarStart,
|
||||
@@ -3362,6 +3370,7 @@ export function App({
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
renderAssistantTurnFooter,
|
||||
assistantFeedback,
|
||||
getAssistantSourcesIcon,
|
||||
sourceReferences,
|
||||
renderComposerToolbarStart,
|
||||
|
||||
@@ -87,6 +87,16 @@ export interface DaemonMessageTodoItem {
|
||||
*/
|
||||
export interface DaemonMessageMeta {
|
||||
backgroundTurn?: DaemonBackgroundTurn;
|
||||
/**
|
||||
* Admitted prompt this message belongs to, copied from the daemon-stamped
|
||||
* `promptId` of the transcript blocks it was built from.
|
||||
*
|
||||
* Unlike a block id (a per-projection ordinal), this survives a reload: the
|
||||
* live stream stamps it on assistant blocks, and a replay stamps it on the
|
||||
* user block, which is the same value the turn's persisted record carries.
|
||||
* Undefined for locally appended messages the daemon has not echoed yet.
|
||||
*/
|
||||
promptId?: string;
|
||||
/**
|
||||
* Wall-clock epoch milliseconds when the backing transcript block was first
|
||||
* observed, populated from `serverTimestamp ?? clientReceivedAt`. Surfaced
|
||||
|
||||
@@ -5810,3 +5810,29 @@ it('does not let old execution output consume or relabel a newer result for the
|
||||
).toBe(false);
|
||||
expect(after[0]).toMatchObject({ data: { backgroundTask: firstTask } });
|
||||
});
|
||||
|
||||
describe('transcript message prompt ids', () => {
|
||||
it('carries the daemon-stamped prompt id onto the messages it built', () => {
|
||||
const messages = transcriptBlocksToDaemonMessages([
|
||||
textBlock('user-1', 'user', 'hello', 1000, false, {
|
||||
promptId: 'prompt-1',
|
||||
}),
|
||||
textBlock('assistant-2', 'assistant', 'hi', 1100),
|
||||
textBlock('user-3', 'user', 'again', 2000, false, {
|
||||
promptId: 'prompt-2',
|
||||
}),
|
||||
textBlock('assistant-4', 'assistant', 'sure', 2100, false, {
|
||||
promptId: 'prompt-2',
|
||||
}),
|
||||
]);
|
||||
const promptIdOf = (id: string) =>
|
||||
messages.find((message) => message.id === id)?.promptId;
|
||||
|
||||
// A replayed turn stamps the prompt's own block; a live turn may stamp the
|
||||
// answer instead. Both carry the same value.
|
||||
expect(promptIdOf('user-1')).toBe('prompt-1');
|
||||
expect(promptIdOf('assistant-4')).toBe('prompt-2');
|
||||
// A block without a stamp must not have one invented for it.
|
||||
expect(promptIdOf('assistant-2')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1269,10 +1269,41 @@ export function transcriptBlocksToDaemonMessages(
|
||||
}
|
||||
|
||||
synchronizeToolGroupSourceIdentity(messages);
|
||||
attachTurnPromptIds(messages, blocks);
|
||||
if (!retainSourceIdentity) stripSourceIdentity(messages);
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the daemon-stamped per-turn `promptId` from contributing blocks onto
|
||||
* the messages built from them.
|
||||
*
|
||||
* A block id is only an ordinal within one projection, so it cannot identify a
|
||||
* turn across a reload; `promptId` can. It is attached regardless of
|
||||
* `includeSourceIdentity`, which governs the tool-group record identity that
|
||||
* host source references use, not turn identity.
|
||||
*/
|
||||
function attachTurnPromptIds(
|
||||
messages: DaemonMessage[],
|
||||
blocks: readonly DaemonTranscriptBlock[],
|
||||
): void {
|
||||
const promptIdByBlockId = new Map<string, string>();
|
||||
for (const block of blocks) {
|
||||
if (block.promptId) promptIdByBlockId.set(block.id, block.promptId);
|
||||
}
|
||||
if (promptIdByBlockId.size === 0) return;
|
||||
for (const message of messages) {
|
||||
if (message.promptId) continue;
|
||||
for (const id of [message.id, ...(message.sourceBlockIds ?? [])]) {
|
||||
const promptId = promptIdByBlockId.get(id);
|
||||
if (promptId) {
|
||||
message.promptId = promptId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function synchronizeToolGroupSourceIdentity(messages: DaemonMessage[]): void {
|
||||
const collect = (tools: readonly DaemonMessageToolCall[]): string[] => {
|
||||
const ids: string[] = [];
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '../adapters/types';
|
||||
import { CompactModeContext } from '../WebShellContexts';
|
||||
import type {
|
||||
WebShellAssistantFeedbackRating,
|
||||
WebShellAssistantTurnFooterRenderInfo,
|
||||
WebShellSource,
|
||||
} from '../customization';
|
||||
@@ -75,6 +76,16 @@ interface MessageItemProps {
|
||||
branchRecordId?: string;
|
||||
showAssistantActions?: boolean;
|
||||
showAssistantBranch?: boolean;
|
||||
/** Turn id marks are keyed by; unset hides the marks for this message. */
|
||||
assistantFeedbackTurnId?: string;
|
||||
/** Admitted prompt id of the turn; unset hides the marks for this message. */
|
||||
assistantFeedbackPromptId?: string;
|
||||
assistantFeedbackRating?: WebShellAssistantFeedbackRating;
|
||||
onAssistantFeedbackRate?: (
|
||||
promptId: string,
|
||||
turnId: string,
|
||||
rating: WebShellAssistantFeedbackRating | null,
|
||||
) => void;
|
||||
isLocateFlashing?: boolean;
|
||||
assistantTurnFooterInfo?: WebShellAssistantTurnFooterRenderInfo;
|
||||
turnSources?: readonly WebShellSource[];
|
||||
@@ -102,6 +113,10 @@ export const MessageItem = memo(function MessageItem({
|
||||
branchRecordId,
|
||||
showAssistantActions = false,
|
||||
showAssistantBranch = false,
|
||||
assistantFeedbackTurnId,
|
||||
assistantFeedbackPromptId,
|
||||
assistantFeedbackRating,
|
||||
onAssistantFeedbackRate,
|
||||
isLocateFlashing = false,
|
||||
assistantTurnFooterInfo,
|
||||
turnSources,
|
||||
@@ -143,6 +158,22 @@ export const MessageItem = memo(function MessageItem({
|
||||
: undefined,
|
||||
[onBranchSession, branchRecordId],
|
||||
);
|
||||
const boundFeedbackRate = useMemo(
|
||||
() =>
|
||||
onAssistantFeedbackRate && assistantFeedbackPromptId
|
||||
? (rating: WebShellAssistantFeedbackRating | null) =>
|
||||
onAssistantFeedbackRate(
|
||||
assistantFeedbackPromptId,
|
||||
assistantFeedbackTurnId ?? '',
|
||||
rating,
|
||||
)
|
||||
: undefined,
|
||||
[
|
||||
onAssistantFeedbackRate,
|
||||
assistantFeedbackPromptId,
|
||||
assistantFeedbackTurnId,
|
||||
],
|
||||
);
|
||||
const compactMode = useContext(CompactModeContext);
|
||||
const questionTool =
|
||||
message.role === 'tool_group' &&
|
||||
@@ -187,6 +218,9 @@ export const MessageItem = memo(function MessageItem({
|
||||
onBranchSession={boundBranchSession}
|
||||
showFooterActions={showAssistantActions}
|
||||
showBranchAction={showAssistantBranch}
|
||||
showAssistantFeedback={assistantFeedbackPromptId !== undefined}
|
||||
assistantFeedbackRating={assistantFeedbackRating}
|
||||
onAssistantFeedbackRate={boundFeedbackRate}
|
||||
isLocateFlashing={isLocateFlashing}
|
||||
customFooterInfo={assistantTurnFooterInfo}
|
||||
turnSources={turnSources}
|
||||
@@ -431,6 +465,14 @@ function areMessageItemPropsEqual(
|
||||
if (prev.branchRecordId !== next.branchRecordId) return false;
|
||||
if (prev.showAssistantActions !== next.showAssistantActions) return false;
|
||||
if (prev.showAssistantBranch !== next.showAssistantBranch) return false;
|
||||
if (prev.assistantFeedbackTurnId !== next.assistantFeedbackTurnId)
|
||||
return false;
|
||||
if (prev.assistantFeedbackPromptId !== next.assistantFeedbackPromptId)
|
||||
return false;
|
||||
if (prev.assistantFeedbackRating !== next.assistantFeedbackRating)
|
||||
return false;
|
||||
if (prev.onAssistantFeedbackRate !== next.onAssistantFeedbackRate)
|
||||
return false;
|
||||
if (prev.isLocateFlashing !== next.isLocateFlashing) return false;
|
||||
if (prev.generateContent !== next.generateContent) return false;
|
||||
if (
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { CompactModeContext } from '../WebShellContexts';
|
||||
import {
|
||||
useWebShellCustomization,
|
||||
type WebShellAssistantFeedbackRating,
|
||||
type WebShellAssistantTurnFooterRenderInfo,
|
||||
type WebShellSource,
|
||||
} from '../customization';
|
||||
@@ -51,6 +52,12 @@ import { useI18n } from '../i18n';
|
||||
import { formatContextTokens } from '../utils/formatTokenCount';
|
||||
import { useWebShellPortalRoot } from '../portalRoot';
|
||||
import { useTranscriptRenderMode } from '../transcriptRenderMode';
|
||||
import { useAssistantFeedback } from '../hooks/useAssistantFeedback';
|
||||
import {
|
||||
feedbackUserMessageOf,
|
||||
notifyAssistantFeedback,
|
||||
shouldOfferAssistantFeedback,
|
||||
} from '../utils/assistantFeedback';
|
||||
import { MessageItem } from './MessageItem';
|
||||
import { summaryRunFirstMemberId, summaryRunId } from './summaryRunId';
|
||||
import type { SessionContentGenerator } from './messages/AssistantMessage';
|
||||
@@ -3493,8 +3500,56 @@ export const MessageList = memo(
|
||||
// (collapsed once complete). `displayItems` stays the full, pre-collapse
|
||||
// list — used only to locate rows hidden inside a collapsed turn — while
|
||||
// `visibleItems` is what actually renders.
|
||||
const { collapseCompletedTurns, sourceReferences } =
|
||||
const { collapseCompletedTurns, sourceReferences, assistantFeedback } =
|
||||
useWebShellCustomization();
|
||||
// `sourceSessionId` is the transcript's own session, which is what a mark
|
||||
// belongs to.
|
||||
const {
|
||||
ratings: assistantFeedbackRatings,
|
||||
rate: rateAssistantFeedback,
|
||||
ratingForTurn: assistantFeedbackRatingForTurn,
|
||||
} = useAssistantFeedback(sourceSessionId);
|
||||
// A turn's mark is keyed by the daemon-stamped prompt id, which a replay
|
||||
// only carries on the prompt's own (turn-head) block — see
|
||||
// `shouldOfferAssistantFeedback` and the adapter's prompt-id pass.
|
||||
const assistantFeedbackHeadById = useMemo(() => {
|
||||
const heads = new Map<string, Message>();
|
||||
for (const message of messages) {
|
||||
if (isTurnStartMessage(message)) heads.set(message.id, message);
|
||||
}
|
||||
return heads;
|
||||
}, [messages]);
|
||||
const assistantFeedbackEnabled = shouldOfferAssistantFeedback({
|
||||
renderMode: transcriptRenderMode,
|
||||
sessionId: sourceSessionId,
|
||||
options: assistantFeedback,
|
||||
});
|
||||
// Kept in a ref so a host passing the options object inline does not
|
||||
// invalidate the handler (and therefore every rendered message) per render.
|
||||
const assistantFeedbackOptionsRef = useRef(assistantFeedback);
|
||||
assistantFeedbackOptionsRef.current = assistantFeedback;
|
||||
const messagesRef = useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
const handleAssistantFeedbackRate = useCallback(
|
||||
(
|
||||
promptId: string,
|
||||
turnId: string,
|
||||
rating: WebShellAssistantFeedbackRating | null,
|
||||
) => {
|
||||
const previousRating = assistantFeedbackRatingForTurn(promptId);
|
||||
rateAssistantFeedback(promptId, rating);
|
||||
const onRate = assistantFeedbackOptionsRef.current?.onRate;
|
||||
if (!onRate) return;
|
||||
notifyAssistantFeedback(onRate, {
|
||||
rating,
|
||||
previousRating,
|
||||
sessionId: sourceSessionId,
|
||||
promptId,
|
||||
userMessage: feedbackUserMessageOf(messagesRef.current, turnId),
|
||||
});
|
||||
},
|
||||
[assistantFeedbackRatingForTurn, rateAssistantFeedback, sourceSessionId],
|
||||
);
|
||||
const sourcesByTurnCache = useRef<
|
||||
| {
|
||||
sourceMessages: readonly Message[];
|
||||
@@ -5604,6 +5659,13 @@ export const MessageList = memo(
|
||||
},
|
||||
};
|
||||
}
|
||||
const feedbackHead = finalAssistantTurnId
|
||||
? assistantFeedbackHeadById.get(finalAssistantTurnId)
|
||||
: undefined;
|
||||
// A live turn stamps the answer's own block while a replayed turn
|
||||
// only stamps the prompt's; both carry the same value.
|
||||
const feedbackPromptId =
|
||||
displayItem.message.promptId ?? feedbackHead?.promptId;
|
||||
const branchRecordId =
|
||||
displayItem.message.role === 'assistant'
|
||||
? displayItem.message.branchRecordId
|
||||
@@ -5688,6 +5750,18 @@ export const MessageList = memo(
|
||||
!isResponding &&
|
||||
branchRecordId !== undefined
|
||||
}
|
||||
assistantFeedbackTurnId={
|
||||
assistantFeedbackEnabled ? finalAssistantTurnId : undefined
|
||||
}
|
||||
assistantFeedbackPromptId={
|
||||
assistantFeedbackEnabled ? feedbackPromptId : undefined
|
||||
}
|
||||
assistantFeedbackRating={
|
||||
assistantFeedbackEnabled && feedbackPromptId
|
||||
? assistantFeedbackRatings[feedbackPromptId]
|
||||
: undefined
|
||||
}
|
||||
onAssistantFeedbackRate={handleAssistantFeedbackRate}
|
||||
isLocateFlashing={displayItemMatchesLocateTarget(
|
||||
displayItem,
|
||||
flashTarget,
|
||||
@@ -5748,6 +5822,10 @@ export const MessageList = memo(
|
||||
visibleItems,
|
||||
flashTarget,
|
||||
finalAssistantTurnIdByAssistantId,
|
||||
assistantFeedbackEnabled,
|
||||
assistantFeedbackHeadById,
|
||||
assistantFeedbackRatings,
|
||||
handleAssistantFeedbackRate,
|
||||
frozenViewport,
|
||||
workspaceCwd,
|
||||
showRetryHint,
|
||||
|
||||
@@ -46,8 +46,35 @@
|
||||
}
|
||||
|
||||
.copyButton svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* A thumb glyph fills its view box far more than the built-in copy and branch
|
||||
icons do, so it takes a smaller box and a proportionally thicker stroke: at
|
||||
13px the 2.4 stroke paints the same ~1.3px as their 16px default, so the
|
||||
three read as one row instead of the thumb looking heavier. */
|
||||
.feedbackButton svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke-width: 2.4;
|
||||
}
|
||||
|
||||
/* The mark reads as state: blue means satisfied, red means not.
|
||||
The hover and focus variants are listed so the colour survives `.copyButton`'s
|
||||
own hover rule, which has a higher specificity: the row only appears on
|
||||
hover, so a mark that greyed out while the pointer was on it would never be
|
||||
readable. */
|
||||
.feedbackButtonActiveUp,
|
||||
.feedbackButtonActiveUp:hover,
|
||||
.feedbackButtonActiveUp:focus-visible {
|
||||
color: var(--agent-blue-400, #335cff);
|
||||
}
|
||||
|
||||
.feedbackButtonActiveDown,
|
||||
.feedbackButtonActiveDown:hover,
|
||||
.feedbackButtonActiveDown:focus-visible {
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
.footerTime {
|
||||
|
||||
@@ -686,3 +686,132 @@ describe('AssistantMessage copy without the async Clipboard API (issue #9485)',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssistantMessage satisfied / not-satisfied marks', () => {
|
||||
const find = (container: HTMLElement, title: string) =>
|
||||
container.querySelector<HTMLButtonElement>(`button[title="${title}"]`);
|
||||
|
||||
it('offers no marks unless the host opts in', () => {
|
||||
const container = render(
|
||||
<AssistantMessage content="answer" showFooterActions />,
|
||||
);
|
||||
expect(find(container, 'Satisfied')).toBeNull();
|
||||
expect(find(container, 'Not satisfied')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers both marks and marks the pressed one', () => {
|
||||
const container = render(
|
||||
<AssistantMessage
|
||||
content="answer"
|
||||
showFooterActions
|
||||
showAssistantFeedback
|
||||
assistantFeedbackRating="up"
|
||||
/>,
|
||||
);
|
||||
expect(find(container, 'Satisfied')?.getAttribute('aria-pressed')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(find(container, 'Not satisfied')?.getAttribute('aria-pressed')).toBe(
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('releases focus after a pointer click but keeps it for keyboard activation', () => {
|
||||
const onRate = vi.fn();
|
||||
const container = render(
|
||||
<AssistantMessage
|
||||
content="answer"
|
||||
showFooterActions
|
||||
showAssistantFeedback
|
||||
onAssistantFeedbackRate={onRate}
|
||||
/>,
|
||||
);
|
||||
const button = find(container, 'Satisfied')!;
|
||||
act(() => button.focus());
|
||||
expect(document.activeElement).toBe(button);
|
||||
|
||||
// Keyboard activation: focus stays, so the hover-only row is still
|
||||
// reachable from the keyboard.
|
||||
act(() => {
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(document.activeElement).toBe(button);
|
||||
|
||||
// Pointer click: a focused button would hold the row open through
|
||||
// `:focus-within` after the pointer leaves.
|
||||
act(() => {
|
||||
button.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, detail: 1 }),
|
||||
);
|
||||
});
|
||||
expect(document.activeElement).not.toBe(button);
|
||||
expect(onRate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('colours the mark by direction', () => {
|
||||
const unmarked = render(
|
||||
<AssistantMessage content="a1" showFooterActions showAssistantFeedback />,
|
||||
);
|
||||
expect(find(unmarked, 'Satisfied')?.className).toContain('feedbackButton');
|
||||
expect(find(unmarked, 'Satisfied')?.className).not.toContain(
|
||||
'feedbackButtonActiveUp',
|
||||
);
|
||||
|
||||
const satisfied = render(
|
||||
<AssistantMessage
|
||||
content="a2"
|
||||
showFooterActions
|
||||
showAssistantFeedback
|
||||
assistantFeedbackRating="up"
|
||||
/>,
|
||||
);
|
||||
expect(find(satisfied, 'Satisfied')?.className).toContain(
|
||||
'feedbackButtonActiveUp',
|
||||
);
|
||||
expect(find(satisfied, 'Not satisfied')?.className).not.toContain(
|
||||
'feedbackButtonActiveDown',
|
||||
);
|
||||
|
||||
const dissatisfied = render(
|
||||
<AssistantMessage
|
||||
content="a3"
|
||||
showFooterActions
|
||||
showAssistantFeedback
|
||||
assistantFeedbackRating="down"
|
||||
/>,
|
||||
);
|
||||
expect(find(dissatisfied, 'Not satisfied')?.className).toContain(
|
||||
'feedbackButtonActiveDown',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the click to the host, and a repeat as a clear', () => {
|
||||
const onRate = vi.fn();
|
||||
const container = render(
|
||||
<AssistantMessage
|
||||
content="answer"
|
||||
showFooterActions
|
||||
showAssistantFeedback
|
||||
assistantFeedbackRating="down"
|
||||
onAssistantFeedbackRate={onRate}
|
||||
/>,
|
||||
);
|
||||
act(() => find(container, 'Not satisfied')?.click());
|
||||
expect(onRate).toHaveBeenCalledWith(null);
|
||||
act(() => find(container, 'Satisfied')?.click());
|
||||
expect(onRate).toHaveBeenLastCalledWith('up');
|
||||
});
|
||||
|
||||
it('translates the mark titles', () => {
|
||||
const container = render(
|
||||
<AssistantMessage
|
||||
content="answer"
|
||||
showFooterActions
|
||||
showAssistantFeedback
|
||||
/>,
|
||||
'zh-CN',
|
||||
);
|
||||
expect(find(container, '满意')).not.toBeNull();
|
||||
expect(find(container, '不满意')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,18 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { LightbulbIcon } from 'lucide-react';
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { LightbulbIcon, ThumbsDownIcon, ThumbsUpIcon } from 'lucide-react';
|
||||
import { Markdown } from './Markdown';
|
||||
import { TurnSources } from '../sources/TurnSources';
|
||||
import {
|
||||
useWebShellCustomization,
|
||||
type WebShellAssistantFeedbackRating,
|
||||
type WebShellAssistantTurnFooterRenderInfo,
|
||||
type WebShellSource,
|
||||
} from '../../customization';
|
||||
@@ -31,6 +40,12 @@ interface AssistantMessageProps {
|
||||
onBranchSession?: () => void | Promise<void>;
|
||||
showFooterActions?: boolean;
|
||||
showBranchAction?: boolean;
|
||||
/** Satisfied / not-satisfied marks are only offered when this is set. */
|
||||
showAssistantFeedback?: boolean;
|
||||
assistantFeedbackRating?: WebShellAssistantFeedbackRating;
|
||||
onAssistantFeedbackRate?: (
|
||||
rating: WebShellAssistantFeedbackRating | null,
|
||||
) => void;
|
||||
isLocateFlashing?: boolean;
|
||||
customFooterInfo?: WebShellAssistantTurnFooterRenderInfo;
|
||||
turnSources?: readonly WebShellSource[];
|
||||
@@ -44,6 +59,9 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
onBranchSession,
|
||||
showFooterActions = false,
|
||||
showBranchAction = false,
|
||||
showAssistantFeedback = false,
|
||||
assistantFeedbackRating,
|
||||
onAssistantFeedbackRate,
|
||||
isLocateFlashing = false,
|
||||
customFooterInfo,
|
||||
turnSources,
|
||||
@@ -84,6 +102,36 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
})
|
||||
.catch(warnClipboardWriteFailure);
|
||||
}, [content, flashCopied]);
|
||||
// Clicking the lit icon clears the mark; clicking the other one switches it.
|
||||
const handleFeedback = useCallback(
|
||||
(
|
||||
rating: WebShellAssistantFeedbackRating,
|
||||
event: ReactMouseEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (!onAssistantFeedbackRate) return;
|
||||
onAssistantFeedbackRate(
|
||||
assistantFeedbackRating === rating ? null : rating,
|
||||
);
|
||||
// A pointer click leaves the button focused, and the row's
|
||||
// `:focus-within` rule would then pin this hover-only row open after the
|
||||
// pointer leaves. Keyboard activation reports detail 0, so it keeps focus
|
||||
// and the row stays reachable from the keyboard.
|
||||
if (event.detail > 0) event.currentTarget.blur();
|
||||
},
|
||||
[assistantFeedbackRating, onAssistantFeedbackRate],
|
||||
);
|
||||
const feedbackButtonClass = useCallback(
|
||||
(rating: WebShellAssistantFeedbackRating) => {
|
||||
const activeClass =
|
||||
rating === 'up'
|
||||
? styles.feedbackButtonActiveUp
|
||||
: styles.feedbackButtonActiveDown;
|
||||
return `${styles.copyButton} ${styles.feedbackButton}${
|
||||
assistantFeedbackRating === rating ? ` ${activeClass}` : ''
|
||||
}`;
|
||||
},
|
||||
[assistantFeedbackRating],
|
||||
);
|
||||
return (
|
||||
<div className={styles.message}>
|
||||
{content && (
|
||||
@@ -117,6 +165,30 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
)}
|
||||
{showFooterActions && showAssistantFeedback && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={feedbackButtonClass('up')}
|
||||
title={t('assistant.satisfied')}
|
||||
aria-label={t('assistant.satisfied')}
|
||||
aria-pressed={assistantFeedbackRating === 'up'}
|
||||
onClick={(event) => handleFeedback('up', event)}
|
||||
>
|
||||
<ThumbsUpIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={feedbackButtonClass('down')}
|
||||
title={t('assistant.dissatisfied')}
|
||||
aria-label={t('assistant.dissatisfied')}
|
||||
aria-pressed={assistantFeedbackRating === 'down'}
|
||||
onClick={(event) => handleFeedback('down', event)}
|
||||
>
|
||||
<ThumbsDownIcon />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{showFooterActions && showBranchAction && onBranchSession && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -317,6 +317,51 @@ export type AssistantTurnFooterRenderer = (
|
||||
info: WebShellAssistantTurnFooterRenderInfo,
|
||||
) => ReactNode | null | undefined;
|
||||
|
||||
export type WebShellAssistantFeedbackRating = 'up' | 'down';
|
||||
|
||||
/** The prompt that started a marked turn. */
|
||||
export interface WebShellAssistantFeedbackUserMessage {
|
||||
/**
|
||||
* The prompt's most recent characters, or a placeholder naming what the
|
||||
* prompt carried when it had no text at all.
|
||||
*/
|
||||
text: string;
|
||||
/** Wall-clock epoch ms of the prompt, when the transcript knows it. */
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface WebShellAssistantFeedbackInfo {
|
||||
/** The new mark; `null` when the user cleared it by clicking the lit icon. */
|
||||
rating: WebShellAssistantFeedbackRating | null;
|
||||
/** The mark the turn carried before this change, when there was one. */
|
||||
previousRating?: WebShellAssistantFeedbackRating;
|
||||
/** The session the marked turn belongs to. */
|
||||
sessionId?: string;
|
||||
/**
|
||||
* Admitted prompt id of the marked turn. This is the daemon's own per-turn
|
||||
* identity: unlike a transcript block id it survives a reload, and it is
|
||||
* what the daemon addresses a turn by (`/session/:id/turns/:promptId`).
|
||||
*/
|
||||
promptId: string;
|
||||
/** The prompt that started the marked turn. */
|
||||
userMessage: WebShellAssistantFeedbackUserMessage;
|
||||
}
|
||||
|
||||
export type AssistantFeedbackHandler = (
|
||||
info: WebShellAssistantFeedbackInfo,
|
||||
) => void;
|
||||
|
||||
export interface WebShellAssistantFeedbackOptions {
|
||||
/** Passing the object shows the icons; `false` hides them again. */
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Notified after every change, including clearing a mark. This is a
|
||||
* notification, not a gate: the icon is already lit (or dark) when it runs,
|
||||
* and neither a throw nor a later rejection changes that.
|
||||
*/
|
||||
onRate?: AssistantFeedbackHandler;
|
||||
}
|
||||
|
||||
/** Return custom artifact artwork, or null/undefined/false for the built-in icon. */
|
||||
export type ArtifactImageRenderer = (
|
||||
artifact: DaemonSessionArtifact,
|
||||
@@ -640,6 +685,11 @@ export interface WebShellCustomization {
|
||||
renderComposerTagTooltip?: ComposerTagRenderer;
|
||||
onComposerTagClick?: ComposerTagClickHandler;
|
||||
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
|
||||
/**
|
||||
* Satisfied / not-satisfied marks on each completed assistant turn. Omit the
|
||||
* object to leave Web Shell's answer footer unchanged.
|
||||
*/
|
||||
assistantFeedback?: WebShellAssistantFeedbackOptions;
|
||||
getAssistantSourcesIcon?: WebShellSourceIconResolver;
|
||||
sourceReferences?: readonly WebShellSourceReference[];
|
||||
renderComposerToolbarStart?: ComposerToolbarStartRenderer;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { WebShellAssistantFeedbackRating } from '../customization';
|
||||
import { AssistantMessage } from '../components/messages/AssistantMessage';
|
||||
import { I18nProvider } from '../i18n';
|
||||
import { ASSISTANT_FEEDBACK_STORAGE_KEY } from '../utils/assistantFeedback';
|
||||
import { useAssistantFeedback } from './useAssistantFeedback';
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const TURN_ID = 'turn-1';
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
interface HostCall {
|
||||
rating: WebShellAssistantFeedbackRating | null;
|
||||
previousRating?: WebShellAssistantFeedbackRating;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the wiring in MessageList + MessageItem: the hook owns the mark, the
|
||||
* message reports the click, and the host is told afterwards.
|
||||
*/
|
||||
function Transcript({
|
||||
sessionId,
|
||||
onHostRate,
|
||||
}: {
|
||||
sessionId: string;
|
||||
onHostRate?: (call: HostCall) => void;
|
||||
}) {
|
||||
const { ratings, rate, ratingForTurn } = useAssistantFeedback(sessionId);
|
||||
return (
|
||||
<AssistantMessage
|
||||
content="answer"
|
||||
showFooterActions
|
||||
showAssistantFeedback
|
||||
assistantFeedbackRating={ratings[TURN_ID]}
|
||||
onAssistantFeedbackRate={(rating) => {
|
||||
const previousRating = ratingForTurn(TURN_ID);
|
||||
rate(TURN_ID, rating);
|
||||
onHostRate?.({ rating, previousRating });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function mount(
|
||||
sessionId = 'session-1',
|
||||
onHostRate?: (call: HostCall) => void,
|
||||
): void {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<I18nProvider language="en">
|
||||
<Transcript sessionId={sessionId} onHostRate={onHostRate} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount(): void {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
}
|
||||
|
||||
const button = (title: string) =>
|
||||
container.querySelector<HTMLButtonElement>(`button[title="${title}"]`);
|
||||
const isLit = (title: string) =>
|
||||
button(title)?.getAttribute('aria-pressed') === 'true';
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('useAssistantFeedback through the answer footer', () => {
|
||||
it('marks the answer immediately, without a host handler', () => {
|
||||
mount();
|
||||
act(() => button('Satisfied')?.click());
|
||||
expect(isLit('Satisfied')).toBe(true);
|
||||
expect(isLit('Not satisfied')).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('tells the host, and switches without clearing first', () => {
|
||||
const calls: HostCall[] = [];
|
||||
mount('session-1', (call) => calls.push(call));
|
||||
act(() => button('Satisfied')?.click());
|
||||
expect(calls).toEqual([{ rating: 'up', previousRating: undefined }]);
|
||||
act(() => button('Not satisfied')?.click());
|
||||
expect(calls).toEqual([
|
||||
{ rating: 'up', previousRating: undefined },
|
||||
{ rating: 'down', previousRating: 'up' },
|
||||
]);
|
||||
expect(isLit('Not satisfied')).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('clears the mark when the lit icon is clicked again', () => {
|
||||
const calls: HostCall[] = [];
|
||||
mount('session-1', (call) => calls.push(call));
|
||||
act(() => button('Satisfied')?.click());
|
||||
act(() => button('Satisfied')?.click());
|
||||
expect(isLit('Satisfied')).toBe(false);
|
||||
expect(calls[1]).toEqual({ rating: null, previousRating: 'up' });
|
||||
expect(
|
||||
window.localStorage.getItem(ASSISTANT_FEEDBACK_STORAGE_KEY),
|
||||
).not.toContain(TURN_ID);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('restores the mark after a reload', () => {
|
||||
mount();
|
||||
act(() => button('Not satisfied')?.click());
|
||||
unmount();
|
||||
|
||||
mount();
|
||||
expect(isLit('Not satisfied')).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps marks per session', () => {
|
||||
mount('session-1');
|
||||
act(() => button('Satisfied')?.click());
|
||||
unmount();
|
||||
|
||||
mount('session-2');
|
||||
expect(isLit('Satisfied')).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
readAssistantFeedbackStore,
|
||||
setAssistantFeedbackRating,
|
||||
writeAssistantFeedbackStore,
|
||||
type AssistantFeedbackRating,
|
||||
type AssistantFeedbackStore,
|
||||
} from '../utils/assistantFeedback';
|
||||
|
||||
const NO_RATINGS: Record<string, AssistantFeedbackRating> = {};
|
||||
|
||||
export interface UseAssistantFeedbackResult {
|
||||
/** Marks for the current session, keyed by turn prompt id. */
|
||||
ratings: Readonly<Record<string, AssistantFeedbackRating>>;
|
||||
/** Marks `promptId` with `rating`, or clears it when `rating` is null. */
|
||||
rate(promptId: string, rating: AssistantFeedbackRating | null): void;
|
||||
/** Current mark for a turn, for the host payload's `previousRating`. */
|
||||
ratingForTurn(
|
||||
promptId: string | undefined,
|
||||
): AssistantFeedbackRating | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the satisfied / not-satisfied marks for one transcript.
|
||||
*
|
||||
* State lives here rather than in a message row because the transcript
|
||||
* virtualizes: a row that scrolls out is unmounted and rebuilt.
|
||||
*/
|
||||
export function useAssistantFeedback(
|
||||
sessionId: string | undefined,
|
||||
): UseAssistantFeedbackResult {
|
||||
const [store, setStore] = useState<AssistantFeedbackStore>(
|
||||
readAssistantFeedbackStore,
|
||||
);
|
||||
// Hydrating is not an edit, so the first render must not write back.
|
||||
const hydratedStore = useRef(store);
|
||||
const storeRef = useRef(store);
|
||||
storeRef.current = store;
|
||||
|
||||
const rate = useCallback(
|
||||
(turnId: string, rating: AssistantFeedbackRating | null) => {
|
||||
if (!sessionId || !turnId) return;
|
||||
setStore((current) =>
|
||||
setAssistantFeedbackRating(current, sessionId, turnId, rating),
|
||||
);
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const ratingForTurn = useCallback(
|
||||
(turnId: string | undefined) =>
|
||||
sessionId && turnId ? storeRef.current[sessionId]?.[turnId] : undefined,
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (store === hydratedStore.current) return;
|
||||
writeAssistantFeedbackStore(store);
|
||||
}, [store]);
|
||||
|
||||
return {
|
||||
ratings: (sessionId && store[sessionId]) || NO_RATINGS,
|
||||
rate,
|
||||
ratingForTurn,
|
||||
};
|
||||
}
|
||||
@@ -618,6 +618,8 @@ const EN: Messages = {
|
||||
'approval.option.allowAlwaysTool': 'Always allow for this tool',
|
||||
'assistant.branch': 'Branch',
|
||||
'assistant.copy': 'Copy',
|
||||
'assistant.dissatisfied': 'Not satisfied',
|
||||
'assistant.satisfied': 'Satisfied',
|
||||
'at.category.extensions': 'Extensions',
|
||||
'at.category.extensions.description': 'Reference active extensions',
|
||||
'at.category.files': 'Files',
|
||||
@@ -4539,6 +4541,8 @@ const ZH: Messages = {
|
||||
'approval.option.allowAlwaysTool': '对此工具始终允许',
|
||||
'assistant.branch': '分叉',
|
||||
'assistant.copy': '复制',
|
||||
'assistant.dissatisfied': '不满意',
|
||||
'assistant.satisfied': '满意',
|
||||
'at.category.extensions': '扩展',
|
||||
'at.category.extensions.description': '引用已启用扩展',
|
||||
'at.category.files': '文件',
|
||||
|
||||
@@ -269,6 +269,11 @@ export type {
|
||||
WebShellSessionArtifactsChangeReason,
|
||||
WebShellModelInfo,
|
||||
WebShellSkillInfo,
|
||||
WebShellAssistantFeedbackInfo,
|
||||
WebShellAssistantFeedbackOptions,
|
||||
WebShellAssistantFeedbackRating,
|
||||
WebShellAssistantFeedbackUserMessage,
|
||||
AssistantFeedbackHandler,
|
||||
} from './customization';
|
||||
export type { WelcomeHeaderProps } from './components/WelcomeHeader';
|
||||
export type {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ASSISTANT_FEEDBACK_STORAGE_KEY,
|
||||
MAX_ASSISTANT_FEEDBACK_SESSIONS,
|
||||
describeFeedbackUserMessage,
|
||||
feedbackUserMessageOf,
|
||||
notifyAssistantFeedback,
|
||||
readAssistantFeedbackStore,
|
||||
setAssistantFeedbackRating,
|
||||
shouldOfferAssistantFeedback,
|
||||
writeAssistantFeedbackStore,
|
||||
type AssistantFeedbackStore,
|
||||
} from './assistantFeedback';
|
||||
|
||||
function storeOf(sessionIds: readonly string[]): AssistantFeedbackStore {
|
||||
const store: AssistantFeedbackStore = {};
|
||||
for (const sessionId of sessionIds) {
|
||||
store[sessionId] = { 'turn-1': 'up' };
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
function sessionIds(n: number): string[] {
|
||||
return Array.from({ length: n }, (_, index) => `session-${index}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('readAssistantFeedbackStore', () => {
|
||||
it('round-trips what writeAssistantFeedbackStore persisted', () => {
|
||||
const store = setAssistantFeedbackRating({}, 'session-1', 'turn-1', 'down');
|
||||
writeAssistantFeedbackStore(store);
|
||||
expect(readAssistantFeedbackStore()).toEqual({
|
||||
'session-1': { 'turn-1': 'down' },
|
||||
});
|
||||
});
|
||||
|
||||
it('degrades to nothing marked instead of guessing', () => {
|
||||
window.localStorage.setItem(ASSISTANT_FEEDBACK_STORAGE_KEY, 'not json');
|
||||
expect(readAssistantFeedbackStore()).toEqual({});
|
||||
|
||||
window.localStorage.setItem(
|
||||
ASSISTANT_FEEDBACK_STORAGE_KEY,
|
||||
JSON.stringify({ v: 1, 'session-1': { 'turn-1': 'maybe' } }),
|
||||
);
|
||||
expect(readAssistantFeedbackStore()).toEqual({});
|
||||
|
||||
window.localStorage.setItem(ASSISTANT_FEEDBACK_STORAGE_KEY, '"a string"');
|
||||
expect(readAssistantFeedbackStore()).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores a payload written by another version', () => {
|
||||
window.localStorage.setItem(
|
||||
ASSISTANT_FEEDBACK_STORAGE_KEY,
|
||||
JSON.stringify({ v: 99, 'session-1': { 'turn-1': 'up' } }),
|
||||
);
|
||||
expect(readAssistantFeedbackStore()).toEqual({});
|
||||
});
|
||||
|
||||
it('survives a localStorage that refuses to be read', () => {
|
||||
const getItem = vi
|
||||
.spyOn(Storage.prototype, 'getItem')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('unavailable');
|
||||
});
|
||||
expect(readAssistantFeedbackStore()).toEqual({});
|
||||
// Positive control: without this the assertion above passes on an empty
|
||||
// localStorage even if the spy never intercepts.
|
||||
expect(getItem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeAssistantFeedbackStore', () => {
|
||||
it('never throws when localStorage refuses to be written', () => {
|
||||
const setItem = vi
|
||||
.spyOn(Storage.prototype, 'setItem')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('quota exceeded');
|
||||
});
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(() =>
|
||||
writeAssistantFeedbackStore(storeOf(['session-1'])),
|
||||
).not.toThrow();
|
||||
expect(setItem).toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps only the most recent sessions', () => {
|
||||
writeAssistantFeedbackStore(storeOf(sessionIds(12)));
|
||||
const stored = readAssistantFeedbackStore();
|
||||
expect(Object.keys(stored)).toHaveLength(MAX_ASSISTANT_FEEDBACK_SESSIONS);
|
||||
expect(Object.keys(stored)).toEqual(sessionIds(12).slice(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAssistantFeedbackRating', () => {
|
||||
it('marks, switches, and clears a turn', () => {
|
||||
const marked = setAssistantFeedbackRating({}, 'session-1', 'turn-1', 'up');
|
||||
expect(marked).toEqual({ 'session-1': { 'turn-1': 'up' } });
|
||||
const switched = setAssistantFeedbackRating(
|
||||
marked,
|
||||
'session-1',
|
||||
'turn-1',
|
||||
'down',
|
||||
);
|
||||
expect(switched).toEqual({ 'session-1': { 'turn-1': 'down' } });
|
||||
expect(
|
||||
setAssistantFeedbackRating(switched, 'session-1', 'turn-1', null),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps a session that is still being rated out of the prune order', () => {
|
||||
const atLimit = storeOf(sessionIds(MAX_ASSISTANT_FEEDBACK_SESSIONS));
|
||||
const edited = setAssistantFeedbackRating(
|
||||
atLimit,
|
||||
'session-0',
|
||||
'turn-1',
|
||||
'down',
|
||||
);
|
||||
// Adding one more session must drop the stalest one, not the just-edited.
|
||||
writeAssistantFeedbackStore({
|
||||
...edited,
|
||||
'session-10': { 'turn-1': 'up' },
|
||||
});
|
||||
const stored = readAssistantFeedbackStore();
|
||||
expect(Object.keys(stored)).toContain('session-0');
|
||||
expect(Object.keys(stored)).not.toContain('session-1');
|
||||
});
|
||||
|
||||
it('ignores an empty session or turn id', () => {
|
||||
const store = storeOf(['session-1']);
|
||||
expect(setAssistantFeedbackRating(store, '', 'turn-1', 'up')).toBe(store);
|
||||
expect(setAssistantFeedbackRating(store, 'session-1', '', 'up')).toBe(
|
||||
store,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldOfferAssistantFeedback', () => {
|
||||
const offer = (overrides: {
|
||||
renderMode?: 'interactive' | 'readonly' | 'document';
|
||||
sessionId?: string;
|
||||
options?: { enabled?: boolean };
|
||||
}) =>
|
||||
shouldOfferAssistantFeedback({
|
||||
renderMode: overrides.renderMode ?? 'interactive',
|
||||
sessionId: 'sessionId' in overrides ? overrides.sessionId : 'session-1',
|
||||
options: 'options' in overrides ? overrides.options : {},
|
||||
});
|
||||
|
||||
it('offers the marks to a live session the host opted in', () => {
|
||||
expect(offer({})).toBe(true);
|
||||
});
|
||||
|
||||
it('stays off when the host did not opt in', () => {
|
||||
expect(offer({ options: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
it('stays off when the host switched it back off', () => {
|
||||
expect(offer({ options: { enabled: false } })).toBe(false);
|
||||
});
|
||||
|
||||
it('stays off without a session to store the mark against', () => {
|
||||
expect(offer({ sessionId: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
it('stays off in a read-only or embedded transcript', () => {
|
||||
expect(offer({ renderMode: 'readonly' })).toBe(false);
|
||||
expect(offer({ renderMode: 'document' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeFeedbackUserMessage', () => {
|
||||
it('keeps the tail of a long prompt', () => {
|
||||
const prompt = `${'a'.repeat(150)}TAIL`;
|
||||
const described = describeFeedbackUserMessage({ content: prompt });
|
||||
expect(described.text).toHaveLength(100);
|
||||
expect(described.text.endsWith('TAIL')).toBe(true);
|
||||
});
|
||||
|
||||
it('trims and forwards a short prompt whole', () => {
|
||||
expect(
|
||||
describeFeedbackUserMessage({ content: ' hello ', timestamp: 42 }),
|
||||
).toEqual({ text: 'hello', timestamp: 42 });
|
||||
});
|
||||
|
||||
it('names what a text-less prompt carried', () => {
|
||||
expect(
|
||||
describeFeedbackUserMessage({ content: '', images: [{}] }).text,
|
||||
).toBe('[图片]');
|
||||
expect(
|
||||
describeFeedbackUserMessage({ content: ' ', files: [{}] }).text,
|
||||
).toBe('[附件]');
|
||||
expect(
|
||||
describeFeedbackUserMessage({
|
||||
content: '',
|
||||
images: [{}, {}],
|
||||
files: [{}],
|
||||
}).text,
|
||||
).toBe('[图片] [附件]');
|
||||
});
|
||||
|
||||
it('leaves the timestamp out when the transcript has none', () => {
|
||||
expect(describeFeedbackUserMessage({ content: 'hi' })).toEqual({
|
||||
text: 'hi',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedbackUserMessageOf', () => {
|
||||
const messages = [
|
||||
{ id: 'u1', role: 'user', content: 'first prompt', timestamp: 10 },
|
||||
{ id: 'a1', role: 'assistant', content: 'answer' },
|
||||
{ id: 's1', role: 'user_shell', command: 'ls -la', timestamp: 20 },
|
||||
];
|
||||
|
||||
it('reports the prompt of the turn it is asked about', () => {
|
||||
expect(feedbackUserMessageOf(messages, 'u1')).toEqual({
|
||||
text: 'first prompt',
|
||||
timestamp: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a shell turn\u2019s command as its prompt', () => {
|
||||
expect(feedbackUserMessageOf(messages, 's1')).toEqual({
|
||||
text: 'ls -la',
|
||||
timestamp: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an unknown prompt rather than guessing one', () => {
|
||||
expect(feedbackUserMessageOf(messages, 'missing')).toEqual({ text: '' });
|
||||
expect(feedbackUserMessageOf(messages, 'a1')).toEqual({ text: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('notifyAssistantFeedback', () => {
|
||||
const info = {
|
||||
rating: 'up' as const,
|
||||
promptId: 'prompt-1',
|
||||
sessionId: 'session-1',
|
||||
userMessage: { text: 'hello', timestamp: 42 },
|
||||
};
|
||||
|
||||
it('hands the mark to the host', () => {
|
||||
const handler = vi.fn();
|
||||
notifyAssistantFeedback(handler, info);
|
||||
expect(handler).toHaveBeenCalledWith(info);
|
||||
});
|
||||
|
||||
it('does nothing without a handler', () => {
|
||||
expect(() => notifyAssistantFeedback(undefined, info)).not.toThrow();
|
||||
});
|
||||
|
||||
it('swallows a throwing handler: the mark is already made', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(() =>
|
||||
notifyAssistantFeedback(() => {
|
||||
throw new Error('host blew up');
|
||||
}, info),
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Persisted satisfied / not-satisfied marks for assistant answers.
|
||||
*
|
||||
* The mark is Web Shell's own UI state: the host is notified through
|
||||
* `customization.assistantFeedback.onRate`, but never feeds state back. Storage
|
||||
* is therefore best-effort and local to this browser — a failure to read or
|
||||
* write must never affect the click that just happened.
|
||||
*/
|
||||
|
||||
import type {
|
||||
WebShellAssistantFeedbackInfo,
|
||||
WebShellAssistantFeedbackOptions,
|
||||
WebShellAssistantFeedbackUserMessage,
|
||||
} from '../customization';
|
||||
import type { TranscriptRenderMode } from '../transcriptRenderMode';
|
||||
|
||||
/**
|
||||
* Whether an assistant answer may carry satisfied / not-satisfied marks.
|
||||
*
|
||||
* Two conditions, both required: a session to store the mark under, and an
|
||||
* interactive rendering — read-only and document transcripts stay untouched
|
||||
* however much session context they carry.
|
||||
*/
|
||||
export function shouldOfferAssistantFeedback({
|
||||
renderMode,
|
||||
sessionId,
|
||||
options,
|
||||
}: {
|
||||
renderMode: TranscriptRenderMode;
|
||||
sessionId?: string;
|
||||
options?: WebShellAssistantFeedbackOptions;
|
||||
}): boolean {
|
||||
return (
|
||||
renderMode === 'interactive' &&
|
||||
sessionId !== undefined &&
|
||||
options !== undefined &&
|
||||
options.enabled !== false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the host about a mark the user just made. The host is a listener, not
|
||||
* an authority: it cannot veto the mark, and a throw here must not reach the
|
||||
* click handler.
|
||||
*/
|
||||
export function notifyAssistantFeedback(
|
||||
handler: ((info: WebShellAssistantFeedbackInfo) => void) | undefined,
|
||||
info: WebShellAssistantFeedbackInfo,
|
||||
): void {
|
||||
if (!handler) return;
|
||||
try {
|
||||
handler(info);
|
||||
} catch (error) {
|
||||
console.warn('[web-shell] assistant feedback handler threw:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export type AssistantFeedbackRating = 'up' | 'down';
|
||||
|
||||
/** Session id -> turn prompt id -> mark. */
|
||||
export type AssistantFeedbackStore = Record<
|
||||
string,
|
||||
Record<string, AssistantFeedbackRating>
|
||||
>;
|
||||
|
||||
export const ASSISTANT_FEEDBACK_STORAGE_KEY =
|
||||
'qwen-web-shell-assistant-feedback';
|
||||
|
||||
/** Sessions kept in storage; the rest age out oldest-first. */
|
||||
export const MAX_ASSISTANT_FEEDBACK_SESSIONS = 10;
|
||||
|
||||
/** Characters of the turn's prompt forwarded to the host. */
|
||||
export const FEEDBACK_USER_MESSAGE_MAX_CHARS = 100;
|
||||
|
||||
/**
|
||||
* Summarises the turn's prompt for the host. Only the tail is kept: the end of
|
||||
* a prompt is what distinguishes it from its neighbours in a list of marks.
|
||||
*/
|
||||
export function describeFeedbackUserMessage(message: {
|
||||
content: string;
|
||||
images?: readonly unknown[];
|
||||
files?: readonly unknown[];
|
||||
timestamp?: number;
|
||||
}): WebShellAssistantFeedbackUserMessage {
|
||||
const trimmed = message.content.trim();
|
||||
const text =
|
||||
trimmed.length > 0
|
||||
? trimmed.slice(-FEEDBACK_USER_MESSAGE_MAX_CHARS)
|
||||
: [
|
||||
(message.images?.length ?? 0) > 0 ? '[图片]' : undefined,
|
||||
(message.files?.length ?? 0) > 0 ? '[附件]' : undefined,
|
||||
]
|
||||
.filter((part): part is string => part !== undefined)
|
||||
.join(' ');
|
||||
return {
|
||||
text,
|
||||
...(message.timestamp !== undefined
|
||||
? { timestamp: message.timestamp }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt that started the turn whose head message is `turnId`. A shell
|
||||
* turn's prompt is its command; anything else is reported as unknown rather
|
||||
* than guessed.
|
||||
*/
|
||||
export function feedbackUserMessageOf(
|
||||
messages: readonly {
|
||||
id: string;
|
||||
role: string;
|
||||
content?: string;
|
||||
command?: string;
|
||||
images?: readonly unknown[];
|
||||
files?: readonly unknown[];
|
||||
timestamp?: number;
|
||||
}[],
|
||||
turnId: string,
|
||||
): WebShellAssistantFeedbackUserMessage {
|
||||
const head = messages.find((message) => message.id === turnId);
|
||||
if (!head || head.role === 'user' || head.role === 'user_shell') {
|
||||
return describeFeedbackUserMessage({
|
||||
content: head?.command ?? head?.content ?? '',
|
||||
images: head?.images,
|
||||
files: head?.files,
|
||||
timestamp: head?.timestamp,
|
||||
});
|
||||
}
|
||||
return describeFeedbackUserMessage({ content: '' });
|
||||
}
|
||||
|
||||
const STORAGE_VERSION = 1;
|
||||
|
||||
const VALID_RATINGS: ReadonlySet<string> = new Set(['up', 'down']);
|
||||
|
||||
function isRating(value: unknown): value is AssistantFeedbackRating {
|
||||
return typeof value === 'string' && VALID_RATINGS.has(value);
|
||||
}
|
||||
|
||||
function keepMostRecentSessions(
|
||||
store: AssistantFeedbackStore,
|
||||
): AssistantFeedbackStore {
|
||||
const sessionIds = Object.keys(store);
|
||||
if (sessionIds.length <= MAX_ASSISTANT_FEEDBACK_SESSIONS) return store;
|
||||
const kept: AssistantFeedbackStore = {};
|
||||
for (const sessionId of sessionIds.slice(-MAX_ASSISTANT_FEEDBACK_SESSIONS)) {
|
||||
kept[sessionId] = store[sessionId]!;
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the persisted marks, dropping anything unrecognizable rather than
|
||||
* guessing: a hand-edited or future payload must degrade to "nothing marked".
|
||||
*/
|
||||
export function readAssistantFeedbackStore(): AssistantFeedbackStore {
|
||||
if (typeof window === 'undefined') return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ASSISTANT_FEEDBACK_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// localStorage can be unavailable in private or embedded contexts, and the
|
||||
// stored value may not be JSON at all.
|
||||
return {};
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||||
const { v, ...sessions } = parsed as Record<string, unknown>;
|
||||
if (v !== undefined && v !== STORAGE_VERSION) return {};
|
||||
|
||||
const store: AssistantFeedbackStore = {};
|
||||
for (const [sessionId, value] of Object.entries(sessions)) {
|
||||
if (
|
||||
!sessionId ||
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const ratings: Record<string, AssistantFeedbackRating> = {};
|
||||
for (const [turnId, rating] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
if (turnId && isRating(rating)) ratings[turnId] = rating;
|
||||
}
|
||||
if (Object.keys(ratings).length > 0) store[sessionId] = ratings;
|
||||
}
|
||||
return keepMostRecentSessions(store);
|
||||
}
|
||||
|
||||
export function writeAssistantFeedbackStore(
|
||||
store: AssistantFeedbackStore,
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
ASSISTANT_FEEDBACK_STORAGE_KEY,
|
||||
JSON.stringify({ v: STORAGE_VERSION, ...keepMostRecentSessions(store) }),
|
||||
);
|
||||
} catch (error) {
|
||||
// A full or disabled localStorage must not break the click that just
|
||||
// happened; the mark still shows for this page.
|
||||
console.warn('[web-shell] failed to persist assistant feedback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new store with `promptId` marked (or cleared when `rating` is
|
||||
* null). Pure so React can run it inside a state updater.
|
||||
*/
|
||||
export function setAssistantFeedbackRating(
|
||||
store: AssistantFeedbackStore,
|
||||
sessionId: string,
|
||||
promptId: string,
|
||||
rating: AssistantFeedbackRating | null,
|
||||
): AssistantFeedbackStore {
|
||||
if (!sessionId || !promptId) return store;
|
||||
const session = { ...(store[sessionId] ?? {}) };
|
||||
if (rating === null) delete session[promptId];
|
||||
else session[promptId] = rating;
|
||||
|
||||
const next: AssistantFeedbackStore = { ...store };
|
||||
// Re-insert the session so its position tracks its last edit: pruning keeps
|
||||
// the tail of the key order, and an existing key keeps its old position.
|
||||
delete next[sessionId];
|
||||
if (Object.keys(session).length > 0) next[sessionId] = session;
|
||||
return next;
|
||||
}
|
||||
Reference in new issue
Block a user