@prettier-ai/dsh-client-ui-message-feedback 0.1.2-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +87 -0
- package/README.zh.md +87 -0
- package/lib/client.js +727 -0
- package/lib/index.js +11 -0
- package/lib/invariant.js +26 -0
- package/lib/types/client/MessageFeedbackActions.d.ts +21 -0
- package/lib/types/client/controller.d.ts +163 -0
- package/lib/types/client/index.d.ts +21 -0
- package/lib/types/client/locales.d.ts +42 -0
- package/lib/types/client/slots.d.ts +50 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +90 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@prettier-ai/dsh-client-ui-message-feedback",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
8
|
+
let react = require("react");
|
|
9
|
+
let react_dom = require("react-dom");
|
|
10
|
+
let _prettier_ai_dsh_client_ui_primitives = require("@prettier-ai/dsh-client-ui-primitives");
|
|
11
|
+
//#region lib/types/client/controller.js
|
|
12
|
+
const INITIAL_VIEW = Object.freeze({
|
|
13
|
+
status: "cold",
|
|
14
|
+
items: /* @__PURE__ */ new Map(),
|
|
15
|
+
error: null
|
|
16
|
+
});
|
|
17
|
+
const OK = Object.freeze({ ok: true });
|
|
18
|
+
const DISPOSED = Object.freeze({
|
|
19
|
+
ok: false,
|
|
20
|
+
error: Object.freeze({
|
|
21
|
+
code: "disposed",
|
|
22
|
+
message: "feedback controller is disposed"
|
|
23
|
+
})
|
|
24
|
+
});
|
|
25
|
+
/** Human-readable text for one business failure code. */
|
|
26
|
+
function describe(code) {
|
|
27
|
+
switch (code) {
|
|
28
|
+
case "session-not-found": return "this session is no longer persisted";
|
|
29
|
+
case "target-not-found": return "this message is not a persisted assistant message";
|
|
30
|
+
case "version-conflict": return "feedback changed elsewhere";
|
|
31
|
+
case "note-blank": return "a note must contain a non-whitespace character";
|
|
32
|
+
case "note-too-large": return "the note is too long";
|
|
33
|
+
default: return code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Build the rejected branch for one business failure code. */
|
|
37
|
+
function fail(code) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
error: {
|
|
41
|
+
code,
|
|
42
|
+
message: describe(code)
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Carrier failure rendered with the Host-supplied code and message. */
|
|
47
|
+
function carrierFailure(error) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
error: {
|
|
51
|
+
code: error.code,
|
|
52
|
+
message: error.message
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Per-session feedback object layer. One instance backs every per-message
|
|
58
|
+
* control in that Session, so a single list read seeds them all.
|
|
59
|
+
*/
|
|
60
|
+
var MessageFeedbackController = class {
|
|
61
|
+
remote;
|
|
62
|
+
sessionId;
|
|
63
|
+
view = INITIAL_VIEW;
|
|
64
|
+
listeners = /* @__PURE__ */ new Set();
|
|
65
|
+
loadPromise = null;
|
|
66
|
+
operationTail = Promise.resolve();
|
|
67
|
+
disposed = false;
|
|
68
|
+
/**
|
|
69
|
+
* @param remote - the messageFeedback Remote namespace.
|
|
70
|
+
* @param sessionId - Session owning every addressed assistant message.
|
|
71
|
+
*/
|
|
72
|
+
constructor(remote, sessionId) {
|
|
73
|
+
this.remote = remote;
|
|
74
|
+
this.sessionId = sessionId;
|
|
75
|
+
}
|
|
76
|
+
/** Return the cached immutable view. */
|
|
77
|
+
getSnapshot = () => this.view;
|
|
78
|
+
/** Subscribe to view replacement. */
|
|
79
|
+
subscribe = (listener) => {
|
|
80
|
+
this.listeners.add(listener);
|
|
81
|
+
return () => {
|
|
82
|
+
this.listeners.delete(listener);
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* Load once; a failed load stays retryable.
|
|
87
|
+
* @returns the settled load result, shared by concurrent callers.
|
|
88
|
+
*/
|
|
89
|
+
ensure() {
|
|
90
|
+
if (this.view.status === "ready") return Promise.resolve(OK);
|
|
91
|
+
return this.refresh();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Re-read the authoritative list, collapsing concurrent callers onto one
|
|
95
|
+
* in-flight read.
|
|
96
|
+
*
|
|
97
|
+
* This is the unserialized read used to seed a cold controller, where no
|
|
98
|
+
* mutation can be in flight yet. A reconnect must use {@link resync} instead:
|
|
99
|
+
* an unserialized list response can otherwise arrive after a newer mutation's
|
|
100
|
+
* reply and overwrite the version that mutation just committed.
|
|
101
|
+
* @returns the settled reload result.
|
|
102
|
+
*/
|
|
103
|
+
refresh() {
|
|
104
|
+
if (this.loadPromise !== null) return this.loadPromise;
|
|
105
|
+
this.publish({
|
|
106
|
+
status: "loading",
|
|
107
|
+
items: this.view.items,
|
|
108
|
+
error: null
|
|
109
|
+
});
|
|
110
|
+
const pending = this.load();
|
|
111
|
+
this.loadPromise = pending;
|
|
112
|
+
return pending.finally(() => {
|
|
113
|
+
this.loadPromise = null;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Re-read the list behind this Session's queued mutations, so a reconnect
|
|
118
|
+
* cannot resurrect a version an in-flight mutation already replaced.
|
|
119
|
+
* @returns the settled reload result.
|
|
120
|
+
*/
|
|
121
|
+
resync() {
|
|
122
|
+
return this.mutate(() => this.refresh(), { seed: false });
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Create or replace feedback for one message, comparing against the version
|
|
126
|
+
* this controller last observed.
|
|
127
|
+
*
|
|
128
|
+
* The note is resolved here rather than by the caller: `mutate` awaits the
|
|
129
|
+
* one list read first, so this body always sees the committed item, while a
|
|
130
|
+
* control that rendered before that read completed would still be holding
|
|
131
|
+
* `undefined`. Omitting `note` therefore keeps whatever is stored; only
|
|
132
|
+
* {@link clearNote} removes one.
|
|
133
|
+
* @param messageId - target assistant message.
|
|
134
|
+
* @param rating - desired judgment.
|
|
135
|
+
* @param note - replacement explanation; omitted keeps the stored note.
|
|
136
|
+
* @returns the settled mutation result.
|
|
137
|
+
*/
|
|
138
|
+
rate(messageId, rating, note) {
|
|
139
|
+
return this.mutate(async () => {
|
|
140
|
+
const observed = this.view.items.get(messageId);
|
|
141
|
+
return await this.putCommitted(messageId, rating, note ?? observed?.note, observed);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Replace one message's rating with the opposite judgment, or retract it when
|
|
146
|
+
* the committed rating already matches. The decision reads the committed item
|
|
147
|
+
* inside the serialized mutation, so a click that lands before the first list
|
|
148
|
+
* read still toggles against the stored value rather than the empty view a
|
|
149
|
+
* cold control rendered.
|
|
150
|
+
* @param messageId - target assistant message.
|
|
151
|
+
* @param rating - the judgment the human asked for.
|
|
152
|
+
* @returns the settled mutation result.
|
|
153
|
+
*/
|
|
154
|
+
toggle(messageId, rating) {
|
|
155
|
+
return this.mutate(async () => {
|
|
156
|
+
const observed = this.view.items.get(messageId);
|
|
157
|
+
if (observed?.rating === rating) return await this.deleteCommitted(messageId, observed);
|
|
158
|
+
return await this.putCommitted(messageId, rating, observed?.note, observed);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Drop the note while keeping the rating. Absent feedback needs no call.
|
|
163
|
+
* @param messageId - target assistant message.
|
|
164
|
+
* @returns the settled mutation result.
|
|
165
|
+
*/
|
|
166
|
+
clearNote(messageId) {
|
|
167
|
+
return this.mutate(async () => {
|
|
168
|
+
const observed = this.view.items.get(messageId);
|
|
169
|
+
if (observed === void 0 || observed.note === void 0) return OK;
|
|
170
|
+
return await this.putCommitted(messageId, observed.rating, void 0, observed);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Remove feedback for one message. A message with no known item is already
|
|
175
|
+
* in the requested state, so no call is made.
|
|
176
|
+
* @param messageId - target assistant message.
|
|
177
|
+
* @returns the settled mutation result.
|
|
178
|
+
*/
|
|
179
|
+
clear(messageId) {
|
|
180
|
+
return this.mutate(async () => {
|
|
181
|
+
const observed = this.view.items.get(messageId);
|
|
182
|
+
if (observed === void 0) return OK;
|
|
183
|
+
return await this.deleteCommitted(messageId, observed);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** Commit one put against the observed version and reconcile a conflict. */
|
|
187
|
+
async putCommitted(messageId, rating, note, observed) {
|
|
188
|
+
const carried = await this.remote.put({
|
|
189
|
+
sessionId: this.sessionId,
|
|
190
|
+
messageId,
|
|
191
|
+
rating,
|
|
192
|
+
...note === void 0 ? {} : { note },
|
|
193
|
+
ifVersion: observed?.version ?? null
|
|
194
|
+
});
|
|
195
|
+
if (!carried.ok) return carrierFailure(carried.error);
|
|
196
|
+
const result = carried.value;
|
|
197
|
+
if (result.ok) {
|
|
198
|
+
this.commit(messageId, result.value);
|
|
199
|
+
return OK;
|
|
200
|
+
}
|
|
201
|
+
if (result.error.code === "version-conflict") this.commit(messageId, result.error.current);
|
|
202
|
+
return fail(result.error.code);
|
|
203
|
+
}
|
|
204
|
+
/** Commit one delete against the observed version and reconcile a conflict. */
|
|
205
|
+
async deleteCommitted(messageId, observed) {
|
|
206
|
+
const carried = await this.remote.delete({
|
|
207
|
+
sessionId: this.sessionId,
|
|
208
|
+
messageId,
|
|
209
|
+
ifVersion: observed.version
|
|
210
|
+
});
|
|
211
|
+
if (!carried.ok) return carrierFailure(carried.error);
|
|
212
|
+
const result = carried.value;
|
|
213
|
+
if (result.ok) {
|
|
214
|
+
this.commit(messageId, null);
|
|
215
|
+
return OK;
|
|
216
|
+
}
|
|
217
|
+
if (result.error.code === "version-conflict") this.commit(messageId, result.error.current);
|
|
218
|
+
return fail(result.error.code);
|
|
219
|
+
}
|
|
220
|
+
/** Drop subscribers and refuse further work when the owning fiber unloads. */
|
|
221
|
+
dispose() {
|
|
222
|
+
this.disposed = true;
|
|
223
|
+
this.listeners.clear();
|
|
224
|
+
}
|
|
225
|
+
/** Fetch the whole sidecar and publish it as the seeded view. */
|
|
226
|
+
async load() {
|
|
227
|
+
try {
|
|
228
|
+
const carried = await this.remote.list({ sessionId: this.sessionId });
|
|
229
|
+
if (this.disposed) return OK;
|
|
230
|
+
if (!carried.ok) {
|
|
231
|
+
this.publish({
|
|
232
|
+
status: "error",
|
|
233
|
+
items: this.view.items,
|
|
234
|
+
error: carried.error.message
|
|
235
|
+
});
|
|
236
|
+
return carrierFailure(carried.error);
|
|
237
|
+
}
|
|
238
|
+
const result = carried.value;
|
|
239
|
+
if (!result.ok) {
|
|
240
|
+
this.publish({
|
|
241
|
+
status: "error",
|
|
242
|
+
items: this.view.items,
|
|
243
|
+
error: describe(result.error.code)
|
|
244
|
+
});
|
|
245
|
+
return fail(result.error.code);
|
|
246
|
+
}
|
|
247
|
+
const items = /* @__PURE__ */ new Map();
|
|
248
|
+
for (const item of result.value.items) items.set(item.messageId, item);
|
|
249
|
+
this.publish({
|
|
250
|
+
status: "ready",
|
|
251
|
+
items,
|
|
252
|
+
error: null
|
|
253
|
+
});
|
|
254
|
+
return OK;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (this.disposed) return OK;
|
|
257
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
258
|
+
this.publish({
|
|
259
|
+
status: "error",
|
|
260
|
+
items: this.view.items,
|
|
261
|
+
error: message
|
|
262
|
+
});
|
|
263
|
+
return {
|
|
264
|
+
ok: false,
|
|
265
|
+
error: {
|
|
266
|
+
code: "transport",
|
|
267
|
+
message
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Serialize one mutation behind this Session's prior mutation so queued
|
|
274
|
+
* operations always compare against the committed version, and translate a
|
|
275
|
+
* transport throw into the same settled shape the controls already render.
|
|
276
|
+
*/
|
|
277
|
+
mutate(operation, options = {}) {
|
|
278
|
+
const guarded = async () => {
|
|
279
|
+
if (this.disposed) return DISPOSED;
|
|
280
|
+
if (options.seed !== false) {
|
|
281
|
+
const loaded = await this.ensure();
|
|
282
|
+
if (!loaded.ok) return loaded;
|
|
283
|
+
if (this.disposed) return DISPOSED;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
return await operation();
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
error: {
|
|
291
|
+
code: "transport",
|
|
292
|
+
message: error instanceof Error ? error.message : String(error)
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
const result = this.operationTail.then(guarded, guarded);
|
|
298
|
+
this.operationTail = result.then(() => void 0);
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Replace one message's entry, keeping every other entry's identity. Only a
|
|
303
|
+
* `mutate` operation reaches this, and `mutate` refuses admission once the
|
|
304
|
+
* controller is disposed, so no disposal guard belongs here; `publish` is
|
|
305
|
+
* the single place that stops notifying after listeners are dropped.
|
|
306
|
+
*/
|
|
307
|
+
commit(messageId, item) {
|
|
308
|
+
const items = new Map(this.view.items);
|
|
309
|
+
if (item === null) items.delete(messageId);
|
|
310
|
+
else items.set(messageId, item);
|
|
311
|
+
this.publish({
|
|
312
|
+
status: "ready",
|
|
313
|
+
items,
|
|
314
|
+
error: null
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/** Replace the view and contain subscriber failures at the observable boundary. */
|
|
318
|
+
publish(view) {
|
|
319
|
+
this.view = Object.freeze(view);
|
|
320
|
+
for (const listener of this.listeners) try {
|
|
321
|
+
listener();
|
|
322
|
+
} catch (error) {
|
|
323
|
+
console.error("[ui-message-feedback] subscriber threw:", error);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region \0dsh-css:/home/runner/work/dsh-publisher/dsh-publisher/upstream/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css.mjs
|
|
329
|
+
const css = ".-ssFWa_action{width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary);cursor:pointer;background:0 0;border:none;border-radius:28px;justify-content:center;align-items:center;padding:6px;display:inline-flex}.-ssFWa_action svg{width:calc(16px + var(--dsh-content-font-delta,0px));height:calc(16px + var(--dsh-content-font-delta,0px))}.-ssFWa_action:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.-ssFWa_action:disabled{cursor:default;opacity:.4}.-ssFWa_action[data-active]{color:var(--dsw-alias-label-primary)}.-ssFWa_noteOpen{max-width:220px;color:var(--dsw-alias-label-tertiary);font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(28px + var(--dsh-content-font-delta,0px));white-space:nowrap;text-overflow:ellipsis;cursor:pointer;background:0 0;border:none;border-radius:14px;padding:0 8px;overflow:hidden}.-ssFWa_noteOpen:hover,.-ssFWa_noteOpen[aria-expanded=true]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.-ssFWa_notePanel{z-index:1100;box-sizing:border-box;border:1px solid var(--dsw-alias-border-inverted);background:var(--dsw-specific-menu);width:320px;max-width:min(360px,100vw - 24px);max-height:calc(100vh - 24px);box-shadow:var(--dsw-shadow-lv3);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border-radius:12px;flex-direction:column;gap:8px;padding:8px;display:flex;position:fixed;overflow-y:auto}.-ssFWa_noteInput{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);width:100%;color:var(--dsw-alias-label-primary);font:inherit;resize:vertical;border-radius:8px;padding:6px 8px;font-size:13px}.-ssFWa_noteActions{justify-content:flex-end;gap:6px;display:flex}.-ssFWa_noteSave,.-ssFWa_noteCancel{cursor:pointer;border:none;border-radius:14px;height:28px;padding:0 10px;font-size:13px}.-ssFWa_noteSave{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}.-ssFWa_noteSave:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}.-ssFWa_noteSave:disabled{cursor:default;opacity:.4}.-ssFWa_noteCancel{color:var(--dsw-alias-label-tertiary);background:0 0}.-ssFWa_noteCancel:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}.-ssFWa_failure{color:var(--dsw-alias-label-tertiary);padding-left:4px;font-size:13px;line-height:20px}";
|
|
330
|
+
const tagId = "@prettier-ai/dsh-client-ui-message-feedback/MessageFeedbackActions.module.css";
|
|
331
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
332
|
+
const tag = document.createElement("style");
|
|
333
|
+
tag.dataset.plugin = "@prettier-ai/dsh-client-ui-message-feedback";
|
|
334
|
+
tag.dataset.pluginCss = tagId;
|
|
335
|
+
tag.textContent = css;
|
|
336
|
+
document.head.appendChild(tag);
|
|
337
|
+
}
|
|
338
|
+
var MessageFeedbackActions_module_css_default = {
|
|
339
|
+
"action": "-ssFWa_action",
|
|
340
|
+
"failure": "-ssFWa_failure",
|
|
341
|
+
"noteActions": "-ssFWa_noteActions",
|
|
342
|
+
"noteCancel": "-ssFWa_noteCancel",
|
|
343
|
+
"noteInput": "-ssFWa_noteInput",
|
|
344
|
+
"noteOpen": "-ssFWa_noteOpen",
|
|
345
|
+
"notePanel": "-ssFWa_notePanel",
|
|
346
|
+
"noteSave": "-ssFWa_noteSave"
|
|
347
|
+
};
|
|
348
|
+
//#endregion
|
|
349
|
+
//#region lib/types/client/MessageFeedbackActions.js
|
|
350
|
+
/**
|
|
351
|
+
* Per-message feedback controls: a Like/Dislike pair plus an optional note.
|
|
352
|
+
* The buttons render inside the assistant message's IconActions row, so they
|
|
353
|
+
* reuse that row's chrome and sit between copy and branch. The note editor is
|
|
354
|
+
* a popover (portaled to `document.body`) anchored to the note trigger, not an
|
|
355
|
+
* inline expansion: a 260px textarea plus buttons cannot fit the row at any
|
|
356
|
+
* viewport, and an inline element pushed the branch action and clock out of the
|
|
357
|
+
* conversation column. Portaling out of the column also escapes its `overflow`
|
|
358
|
+
* clip, so the panel cannot be cropped or detached from the message it annotates.
|
|
359
|
+
* @module @prettier-ai/dsh-client-ui-message-feedback/client/MessageFeedbackActions
|
|
360
|
+
*/
|
|
361
|
+
/** Safe distance kept between the panel and the viewport edges (the Menu portal margin). */
|
|
362
|
+
const PANEL_MARGIN = 12;
|
|
363
|
+
/** Distance between the trigger's bottom edge and the panel's top. */
|
|
364
|
+
const PANEL_GAP = 4;
|
|
365
|
+
/**
|
|
366
|
+
* Unplaced portal panel: hidden but laid out so `offsetWidth` is real for the
|
|
367
|
+
* clamp. The explicit insets match `Menu`'s measure style — a `position: fixed`
|
|
368
|
+
* element with auto insets otherwise sits at its static position, a different
|
|
369
|
+
* origin than the one the first placement measures from.
|
|
370
|
+
*/
|
|
371
|
+
const MEASURE_STYLE = {
|
|
372
|
+
visibility: "hidden",
|
|
373
|
+
left: 0,
|
|
374
|
+
top: 0
|
|
375
|
+
};
|
|
376
|
+
/**
|
|
377
|
+
* One message's feedback controls.
|
|
378
|
+
* @param props - the owner's message identity, the injected verbs, and the
|
|
379
|
+
* shared feedback hook.
|
|
380
|
+
* @returns the rating buttons and the note trigger, with the note editor
|
|
381
|
+
* portal-open beneath the trigger while it is open.
|
|
382
|
+
*/
|
|
383
|
+
function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearNote, useFeedback, t }) {
|
|
384
|
+
const item = useFeedback((view) => view.items.get(messageId));
|
|
385
|
+
const loadFailed = useFeedback((view) => view.status === "error");
|
|
386
|
+
const rating = item?.rating;
|
|
387
|
+
const [noteOpen, setNoteOpen] = (0, react.useState)(false);
|
|
388
|
+
const [draft, setDraft] = (0, react.useState)("");
|
|
389
|
+
const [pending, setPending] = (0, react.useState)(false);
|
|
390
|
+
const [rowFailure, setRowFailure] = (0, react.useState)(null);
|
|
391
|
+
const [noteFailure, setNoteFailure] = (0, react.useState)(null);
|
|
392
|
+
const triggerRef = (0, react.useRef)(null);
|
|
393
|
+
const panelRef = (0, react.useRef)(null);
|
|
394
|
+
const inputRef = (0, react.useRef)(null);
|
|
395
|
+
const seeded = (0, react.useRef)(false);
|
|
396
|
+
const seed = (0, react.useCallback)(() => {
|
|
397
|
+
if (seeded.current) return;
|
|
398
|
+
seeded.current = true;
|
|
399
|
+
ensure();
|
|
400
|
+
}, [ensure]);
|
|
401
|
+
const alive = (0, react.useRef)(true);
|
|
402
|
+
(0, react.useEffect)(() => () => {
|
|
403
|
+
alive.current = false;
|
|
404
|
+
}, []);
|
|
405
|
+
/** Bumped whenever an editing session ends, so a late save can tell it is stale. */
|
|
406
|
+
const noteGeneration = (0, react.useRef)(0);
|
|
407
|
+
/** Current panel open-state, readable from a stale closure via a ref. */
|
|
408
|
+
const noteOpenRef = (0, react.useRef)(false);
|
|
409
|
+
(0, react.useEffect)(() => {
|
|
410
|
+
noteOpenRef.current = noteOpen;
|
|
411
|
+
}, [noteOpen]);
|
|
412
|
+
const errorCopy = (0, react.useCallback)((result) => {
|
|
413
|
+
return result.error?.code === "version-conflict" ? t("error.conflict") : t("error.generic");
|
|
414
|
+
}, [t]);
|
|
415
|
+
const settleRating = (0, react.useCallback)((result) => {
|
|
416
|
+
if (!alive.current) return;
|
|
417
|
+
setPending(false);
|
|
418
|
+
setRowFailure(result.ok ? null : errorCopy(result));
|
|
419
|
+
}, [errorCopy]);
|
|
420
|
+
const closeNote = (0, react.useCallback)(() => {
|
|
421
|
+
noteGeneration.current += 1;
|
|
422
|
+
setNoteOpen(false);
|
|
423
|
+
}, []);
|
|
424
|
+
const onRate = (0, react.useCallback)((next) => {
|
|
425
|
+
setPending(true);
|
|
426
|
+
setRowFailure(null);
|
|
427
|
+
closeNote();
|
|
428
|
+
toggle(messageId, next).then(settleRating);
|
|
429
|
+
}, [
|
|
430
|
+
closeNote,
|
|
431
|
+
messageId,
|
|
432
|
+
settleRating,
|
|
433
|
+
toggle
|
|
434
|
+
]);
|
|
435
|
+
const onSaveNote = (0, react.useCallback)((current) => {
|
|
436
|
+
const trimmed = draft.trim();
|
|
437
|
+
setPending(true);
|
|
438
|
+
setNoteFailure(null);
|
|
439
|
+
const generation = noteGeneration.current;
|
|
440
|
+
const staleSeed = item?.note ?? "";
|
|
441
|
+
(trimmed.length === 0 ? clearNote(messageId) : rate(messageId, current, trimmed)).then((result) => {
|
|
442
|
+
if (!alive.current) return;
|
|
443
|
+
setPending(false);
|
|
444
|
+
if (result.ok) {
|
|
445
|
+
if (generation === noteGeneration.current) {
|
|
446
|
+
setNoteFailure(null);
|
|
447
|
+
setNoteOpen(false);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
setDraft((draftNow) => draftNow === staleSeed ? trimmed : draftNow);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (generation === noteGeneration.current || !noteOpenRef.current) setNoteFailure(errorCopy(result));
|
|
454
|
+
});
|
|
455
|
+
}, [
|
|
456
|
+
clearNote,
|
|
457
|
+
draft,
|
|
458
|
+
errorCopy,
|
|
459
|
+
item?.note,
|
|
460
|
+
messageId,
|
|
461
|
+
noteOpenRef,
|
|
462
|
+
rate
|
|
463
|
+
]);
|
|
464
|
+
const toggleNote = (0, react.useCallback)(() => {
|
|
465
|
+
if (noteOpen) {
|
|
466
|
+
closeNote();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
setDraft(item?.note ?? "");
|
|
470
|
+
setNoteFailure(null);
|
|
471
|
+
setNoteOpen(true);
|
|
472
|
+
}, [
|
|
473
|
+
noteOpen,
|
|
474
|
+
closeNote,
|
|
475
|
+
item?.note
|
|
476
|
+
]);
|
|
477
|
+
const pos = (0, _prettier_ai_dsh_client_ui_primitives.useAnchoredPosition)({
|
|
478
|
+
open: noteOpen,
|
|
479
|
+
anchorRef: triggerRef,
|
|
480
|
+
panelRef,
|
|
481
|
+
gap: PANEL_GAP,
|
|
482
|
+
margin: PANEL_MARGIN
|
|
483
|
+
});
|
|
484
|
+
(0, react.useEffect)(() => {
|
|
485
|
+
if (!noteOpen) return;
|
|
486
|
+
inputRef.current?.focus();
|
|
487
|
+
const onPointerDown = (e) => {
|
|
488
|
+
if (!(e.target instanceof Node)) return;
|
|
489
|
+
if (triggerRef.current?.contains(e.target) === true) return;
|
|
490
|
+
if (panelRef.current?.contains(e.target) === true) return;
|
|
491
|
+
closeNote();
|
|
492
|
+
};
|
|
493
|
+
const onKeyDown = (e) => {
|
|
494
|
+
if (e.key === "Escape") closeNote();
|
|
495
|
+
};
|
|
496
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
497
|
+
document.addEventListener("keydown", onKeyDown);
|
|
498
|
+
return () => {
|
|
499
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
500
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
501
|
+
};
|
|
502
|
+
}, [noteOpen, closeNote]);
|
|
503
|
+
const wasOpen = (0, react.useRef)(false);
|
|
504
|
+
(0, react.useEffect)(() => {
|
|
505
|
+
if (noteOpen) {
|
|
506
|
+
wasOpen.current = true;
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (wasOpen.current) triggerRef.current?.focus();
|
|
510
|
+
wasOpen.current = false;
|
|
511
|
+
}, [noteOpen]);
|
|
512
|
+
const likeLabel = rating === "positive" ? t("action.likeActive") : t("action.like");
|
|
513
|
+
const dislikeLabel = rating === "negative" ? t("action.dislikeActive") : t("action.dislike");
|
|
514
|
+
return (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
515
|
+
(0, react_jsx_runtime.jsx)(_prettier_ai_dsh_client_ui_primitives.Tooltip, {
|
|
516
|
+
label: likeLabel,
|
|
517
|
+
side: "bottom",
|
|
518
|
+
children: (0, react_jsx_runtime.jsx)("button", {
|
|
519
|
+
type: "button",
|
|
520
|
+
className: MessageFeedbackActions_module_css_default.action,
|
|
521
|
+
"aria-label": likeLabel,
|
|
522
|
+
"aria-pressed": rating === "positive",
|
|
523
|
+
"data-active": rating === "positive" || void 0,
|
|
524
|
+
disabled: pending,
|
|
525
|
+
onFocus: seed,
|
|
526
|
+
onPointerEnter: seed,
|
|
527
|
+
onClick: () => {
|
|
528
|
+
onRate("positive");
|
|
529
|
+
},
|
|
530
|
+
children: (0, react_jsx_runtime.jsx)(_prettier_ai_dsh_client_ui_primitives.IconLikeOutline16, {})
|
|
531
|
+
})
|
|
532
|
+
}),
|
|
533
|
+
(0, react_jsx_runtime.jsx)(_prettier_ai_dsh_client_ui_primitives.Tooltip, {
|
|
534
|
+
label: dislikeLabel,
|
|
535
|
+
side: "bottom",
|
|
536
|
+
children: (0, react_jsx_runtime.jsx)("button", {
|
|
537
|
+
type: "button",
|
|
538
|
+
className: MessageFeedbackActions_module_css_default.action,
|
|
539
|
+
"aria-label": dislikeLabel,
|
|
540
|
+
"aria-pressed": rating === "negative",
|
|
541
|
+
"data-active": rating === "negative" || void 0,
|
|
542
|
+
disabled: pending,
|
|
543
|
+
onFocus: seed,
|
|
544
|
+
onPointerEnter: seed,
|
|
545
|
+
onClick: () => {
|
|
546
|
+
onRate("negative");
|
|
547
|
+
},
|
|
548
|
+
children: (0, react_jsx_runtime.jsx)(_prettier_ai_dsh_client_ui_primitives.IconDislikeOutline16, {})
|
|
549
|
+
})
|
|
550
|
+
}),
|
|
551
|
+
rating !== void 0 && (0, react_jsx_runtime.jsx)("button", {
|
|
552
|
+
ref: triggerRef,
|
|
553
|
+
type: "button",
|
|
554
|
+
className: MessageFeedbackActions_module_css_default.noteOpen,
|
|
555
|
+
"aria-haspopup": "dialog",
|
|
556
|
+
"aria-expanded": noteOpen,
|
|
557
|
+
onClick: toggleNote,
|
|
558
|
+
children: item?.note === void 0 ? t("note.open") : item.note
|
|
559
|
+
}),
|
|
560
|
+
rowFailure === null && loadFailed && (0, react_jsx_runtime.jsx)("span", {
|
|
561
|
+
className: MessageFeedbackActions_module_css_default.failure,
|
|
562
|
+
role: "status",
|
|
563
|
+
children: t("error.load")
|
|
564
|
+
}),
|
|
565
|
+
rowFailure !== null && (0, react_jsx_runtime.jsx)("span", {
|
|
566
|
+
className: MessageFeedbackActions_module_css_default.failure,
|
|
567
|
+
role: "status",
|
|
568
|
+
children: rowFailure
|
|
569
|
+
}),
|
|
570
|
+
!(rating !== void 0 && noteOpen) && noteFailure !== null && (0, react_jsx_runtime.jsx)("span", {
|
|
571
|
+
className: MessageFeedbackActions_module_css_default.failure,
|
|
572
|
+
role: "status",
|
|
573
|
+
children: noteFailure
|
|
574
|
+
}),
|
|
575
|
+
rating !== void 0 && noteOpen && (0, react_dom.createPortal)((0, react_jsx_runtime.jsxs)("div", {
|
|
576
|
+
ref: panelRef,
|
|
577
|
+
className: MessageFeedbackActions_module_css_default.notePanel,
|
|
578
|
+
role: "dialog",
|
|
579
|
+
"aria-label": t("note.dialog"),
|
|
580
|
+
style: pos ?? MEASURE_STYLE,
|
|
581
|
+
children: [
|
|
582
|
+
(0, react_jsx_runtime.jsx)("textarea", {
|
|
583
|
+
ref: inputRef,
|
|
584
|
+
className: MessageFeedbackActions_module_css_default.noteInput,
|
|
585
|
+
"aria-label": t("note.aria"),
|
|
586
|
+
placeholder: t("note.placeholder"),
|
|
587
|
+
value: draft,
|
|
588
|
+
rows: 3,
|
|
589
|
+
onChange: (event) => {
|
|
590
|
+
setDraft(event.target.value);
|
|
591
|
+
}
|
|
592
|
+
}),
|
|
593
|
+
(0, react_jsx_runtime.jsxs)("div", {
|
|
594
|
+
className: MessageFeedbackActions_module_css_default.noteActions,
|
|
595
|
+
children: [(0, react_jsx_runtime.jsx)("button", {
|
|
596
|
+
type: "button",
|
|
597
|
+
className: MessageFeedbackActions_module_css_default.noteSave,
|
|
598
|
+
disabled: pending,
|
|
599
|
+
onClick: () => {
|
|
600
|
+
onSaveNote(rating);
|
|
601
|
+
},
|
|
602
|
+
children: t("note.save")
|
|
603
|
+
}), (0, react_jsx_runtime.jsx)("button", {
|
|
604
|
+
type: "button",
|
|
605
|
+
className: MessageFeedbackActions_module_css_default.noteCancel,
|
|
606
|
+
onClick: closeNote,
|
|
607
|
+
children: t("note.cancel")
|
|
608
|
+
})]
|
|
609
|
+
}),
|
|
610
|
+
noteFailure !== null && (0, react_jsx_runtime.jsx)("span", {
|
|
611
|
+
className: MessageFeedbackActions_module_css_default.failure,
|
|
612
|
+
role: "status",
|
|
613
|
+
children: noteFailure
|
|
614
|
+
})
|
|
615
|
+
]
|
|
616
|
+
}), document.body)
|
|
617
|
+
] });
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region lib/types/client/locales.js
|
|
621
|
+
/** `feedback` namespace dictionaries. */
|
|
622
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
623
|
+
const zh = {
|
|
624
|
+
"action.like": "好的回答",
|
|
625
|
+
"action.likeActive": "取消标记",
|
|
626
|
+
"action.dislike": "有问题的回答",
|
|
627
|
+
"action.dislikeActive": "取消标记",
|
|
628
|
+
"note.open": "补充说明",
|
|
629
|
+
"note.dialog": "反馈",
|
|
630
|
+
"note.placeholder": "这条回答哪里好,或哪里有问题?(可选)",
|
|
631
|
+
"note.save": "保存",
|
|
632
|
+
"note.cancel": "取消",
|
|
633
|
+
"note.aria": "反馈说明",
|
|
634
|
+
"error.conflict": "这条反馈已在别处改动,已显示最新状态",
|
|
635
|
+
"error.load": "反馈状态加载失败",
|
|
636
|
+
"error.generic": "反馈保存失败"
|
|
637
|
+
};
|
|
638
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
639
|
+
const en = {
|
|
640
|
+
"action.like": "Good response",
|
|
641
|
+
"action.likeActive": "Remove rating",
|
|
642
|
+
"action.dislike": "Bad response",
|
|
643
|
+
"action.dislikeActive": "Remove rating",
|
|
644
|
+
"note.open": "Add a note",
|
|
645
|
+
"note.dialog": "Feedback",
|
|
646
|
+
"note.placeholder": "What was good, or what went wrong? (optional)",
|
|
647
|
+
"note.save": "Save",
|
|
648
|
+
"note.cancel": "Cancel",
|
|
649
|
+
"note.aria": "Feedback note",
|
|
650
|
+
"error.conflict": "This feedback changed elsewhere; the latest state is shown",
|
|
651
|
+
"error.load": "Could not load feedback",
|
|
652
|
+
"error.generic": "Could not save feedback"
|
|
653
|
+
};
|
|
654
|
+
//#endregion
|
|
655
|
+
//#region lib/types/client/index.js
|
|
656
|
+
/**
|
|
657
|
+
* Message feedback plugin, browser half: the Like/Dislike entry in the
|
|
658
|
+
* conversation.chat.assistant-actions strip. One MessageFeedbackController per
|
|
659
|
+
* Session backs every message control in that Session, so a single list read
|
|
660
|
+
* seeds the whole transcript. Mutations go through the generated
|
|
661
|
+
* messageFeedback Remote; the Host owns per-item compare-and-set.
|
|
662
|
+
* @module @prettier-ai/dsh-client-ui-message-feedback/client
|
|
663
|
+
*/
|
|
664
|
+
/** Dictionary namespace owned by this plugin. */
|
|
665
|
+
const NS = "feedback";
|
|
666
|
+
/** Required services: the slot registry, the Remote namespace, and the copy. */
|
|
667
|
+
const inject = [
|
|
668
|
+
"slots",
|
|
669
|
+
"remote",
|
|
670
|
+
"remote.messageFeedback",
|
|
671
|
+
"locale"
|
|
672
|
+
];
|
|
673
|
+
/**
|
|
674
|
+
* Client plugin body: the per-message feedback entry and its per-session
|
|
675
|
+
* object layer.
|
|
676
|
+
* @param ctx - client root context.
|
|
677
|
+
*/
|
|
678
|
+
function apply(ctx) {
|
|
679
|
+
ctx.effect(() => ctx.locale.register(NS, {
|
|
680
|
+
zh,
|
|
681
|
+
en
|
|
682
|
+
}), "ui-message-feedback: dictionaries");
|
|
683
|
+
const controllers = /* @__PURE__ */ new Map();
|
|
684
|
+
const controllerFor = (sessionId) => {
|
|
685
|
+
let controller = controllers.get(sessionId);
|
|
686
|
+
if (controller === void 0) {
|
|
687
|
+
controller = new MessageFeedbackController(ctx.remote.messageFeedback, sessionId);
|
|
688
|
+
controllers.set(sessionId, controller);
|
|
689
|
+
}
|
|
690
|
+
return controller;
|
|
691
|
+
};
|
|
692
|
+
ctx.on("connection/reset", () => {
|
|
693
|
+
for (const controller of controllers.values()) if (controller.getSnapshot().status !== "cold") controller.resync();
|
|
694
|
+
});
|
|
695
|
+
ctx.slots.inject("conversation.chat.assistant-actions", () => {
|
|
696
|
+
const dispose = ctx.slots.register({
|
|
697
|
+
name: "conversation.chat.assistant-actions",
|
|
698
|
+
id: "feedback",
|
|
699
|
+
order: 10,
|
|
700
|
+
locale: NS,
|
|
701
|
+
inject: (sessionId) => {
|
|
702
|
+
const controller = controllerFor(sessionId);
|
|
703
|
+
return {
|
|
704
|
+
hooks: { feedback: controller },
|
|
705
|
+
ensure: () => controller.ensure(),
|
|
706
|
+
rate: (messageId, rating, note) => controller.rate(messageId, rating, note),
|
|
707
|
+
toggle: (messageId, rating) => controller.toggle(messageId, rating),
|
|
708
|
+
clearNote: (messageId) => controller.clearNote(messageId),
|
|
709
|
+
clear: (messageId) => controller.clear(messageId)
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
}, MessageFeedbackActions);
|
|
713
|
+
return () => {
|
|
714
|
+
dispose();
|
|
715
|
+
for (const controller of controllers.values()) controller.dispose();
|
|
716
|
+
controllers.clear();
|
|
717
|
+
};
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
//#endregion
|
|
721
|
+
exports.apply = apply;
|
|
722
|
+
exports.inject = inject;
|
|
723
|
+
return module.exports;
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
//# sourceMappingURL=client.js.map
|