@meistrari/tela-build 1.65.1 → 1.67.0
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/components/tela/chat/chat.mdx +18 -5
- package/components/tela/chat/message/message-feedback-state.test.ts +226 -0
- package/components/tela/chat/message/message-feedback-state.ts +123 -0
- package/components/tela/chat/message/message-feedback.vue +232 -201
- package/components/tela/rfc/rfc-flowchart.vue +79 -0
- package/components/tela/rfc/rfc-heading.vue +35 -0
- package/components/tela/rfc/rfc-markdown.test.ts +95 -0
- package/components/tela/rfc/rfc-markdown.ts +92 -0
- package/components/tela/rfc/rfc-pre.vue +46 -0
- package/components/tela/rfc/rfc.mdx +131 -0
- package/components/tela/rfc/rfc.vue +329 -0
- package/components/tela/table-of-contents/table-of-contents.mdx +81 -0
- package/components/tela/table-of-contents/table-of-contents.vue +235 -0
- package/docs/interfaces.md +3 -0
- package/package.json +4 -1
|
@@ -726,7 +726,9 @@ const status = computed(() => streaming.value ? 'streaming' : 'ready')
|
|
|
726
726
|
|
|
727
727
|
### Message Feedback
|
|
728
728
|
|
|
729
|
-
Thumbs under an assistant message.
|
|
729
|
+
Thumbs under an assistant message. Feed the saved vote back via `rating` to keep it controlled.
|
|
730
|
+
|
|
731
|
+
Pass `submit` when the vote is persisted remotely: the form disables its controls while the promise is in flight and only clears the draft on `{ ok: true }`. On `{ ok: false, message? }` it stays open with the chips and comment intact and swaps the confirm button to `retryLabel`. A handler that stalls past `submitTimeoutMs` (15s by default) is treated as a failure, so the form can never lock the user out. Without `submit` the component falls back to the `@submit` emit and confirms immediately.
|
|
730
732
|
|
|
731
733
|
```vue
|
|
732
734
|
<script setup lang="ts">
|
|
@@ -738,8 +740,11 @@ const message = ref({
|
|
|
738
740
|
rating: null as FeedbackRating | null,
|
|
739
741
|
})
|
|
740
742
|
|
|
741
|
-
function saveFeedback(payload: { rating: FeedbackRating, reasons: string[], comment: string }) {
|
|
743
|
+
async function saveFeedback(payload: { rating: FeedbackRating, reasons: string[], comment: string }) {
|
|
744
|
+
await new Promise(resolve => setTimeout(resolve, 600))
|
|
742
745
|
message.value.rating = payload.rating
|
|
746
|
+
|
|
747
|
+
return { ok: true as const }
|
|
743
748
|
}
|
|
744
749
|
</script>
|
|
745
750
|
|
|
@@ -756,7 +761,7 @@ function saveFeedback(payload: { rating: FeedbackRating, reasons: string[], comm
|
|
|
756
761
|
:positive-reasons="['Accurate', 'Well written', 'Helpful']"
|
|
757
762
|
:negative-reasons="['Incorrect', 'Off topic', 'Too long']"
|
|
758
763
|
require-reason
|
|
759
|
-
|
|
764
|
+
:submit="saveFeedback"
|
|
760
765
|
/>
|
|
761
766
|
</div>
|
|
762
767
|
</template>
|
|
@@ -1005,7 +1010,7 @@ import { TelaChatIconsAnthropic, TelaChatIconsGemini, TelaChatIconsOpenai } from
|
|
|
1005
1010
|
| `TelaChatMessage` | Aligns one message by author (`from="user" \| "assistant"`). |
|
|
1006
1011
|
| `TelaChatMessageContent` | Message surface: `contained` (bubble) or `flat` (bare text). |
|
|
1007
1012
|
| `TelaChatMessageAvatar` | Avatar displayed alongside a message. |
|
|
1008
|
-
| `TelaChatMessageFeedback` | Thumbs-up/Thumbs-down with reason chips and comment; emits `submit`. |
|
|
1013
|
+
| `TelaChatMessageFeedback` | Thumbs-up/Thumbs-down with reason chips and comment; persists via `submit` or emits `submit`. |
|
|
1009
1014
|
| `TelaChatPromptInput` | Input shell (border, focus surface, `locked` state). |
|
|
1010
1015
|
| `TelaChatPromptInputTextarea` | Contenteditable editor with auto-grow, max-height and scroll fog. Host owns the content state. |
|
|
1011
1016
|
| `TelaChatPromptInputToolbar` | Bottom row of the input: tools on the left, model selector + submit on the right. |
|
|
@@ -1093,13 +1098,21 @@ type MessageAvatarProps = {
|
|
|
1093
1098
|
|
|
1094
1099
|
type FeedbackRating = 'positive' | 'negative'
|
|
1095
1100
|
|
|
1101
|
+
type FeedbackPayload = { rating: FeedbackRating, reasons: string[], comment: string }
|
|
1102
|
+
type FeedbackSubmitResult = { ok: true } | { ok: false, message?: string }
|
|
1103
|
+
|
|
1096
1104
|
type MessageFeedbackProps = {
|
|
1097
1105
|
rating?: FeedbackRating | null
|
|
1098
1106
|
positiveReasons?: string[]
|
|
1099
1107
|
negativeReasons?: string[]
|
|
1100
1108
|
requireReason?: boolean
|
|
1109
|
+
submit?: (payload: FeedbackPayload) => Promise<FeedbackSubmitResult>
|
|
1110
|
+
submitTimeoutMs?: number // default: 15_000
|
|
1111
|
+
submittingLabel?: string // default: 'Submitting…'
|
|
1112
|
+
retryLabel?: string // default: 'Try again'
|
|
1113
|
+
submitErrorMessage?: string // default: 'Could not submit feedback. Try again.'
|
|
1101
1114
|
}
|
|
1102
|
-
// emits: submit(payload:
|
|
1115
|
+
// emits: submit(payload: FeedbackPayload) — only when the `submit` prop is absent
|
|
1103
1116
|
```
|
|
1104
1117
|
|
|
1105
1118
|
### Prompt Input
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
canSubmitNegativeFeedback,
|
|
5
|
+
closeMessageFeedback,
|
|
6
|
+
createMessageFeedbackState,
|
|
7
|
+
openMessageFeedback,
|
|
8
|
+
submitMessageFeedback,
|
|
9
|
+
toggleMessageFeedbackReason,
|
|
10
|
+
} from './message-feedback-state'
|
|
11
|
+
|
|
12
|
+
describe('message feedback submission', () => {
|
|
13
|
+
it('keeps the popover and draft while persistence is pending', async () => {
|
|
14
|
+
const state = createMessageFeedbackState()
|
|
15
|
+
openMessageFeedback(state, 'negative')
|
|
16
|
+
toggleMessageFeedbackReason(state, 'Resposta incorreta')
|
|
17
|
+
state.comment = 'A fonte citada não sustenta a conclusão.'
|
|
18
|
+
|
|
19
|
+
let finish: ((result: { ok: true }) => void) | undefined
|
|
20
|
+
const pending = submitMessageFeedback(
|
|
21
|
+
state,
|
|
22
|
+
'negative',
|
|
23
|
+
async () => await new Promise(resolve => finish = resolve),
|
|
24
|
+
'Não foi possível enviar.',
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
expect(state).toMatchObject({
|
|
28
|
+
openRating: 'negative',
|
|
29
|
+
selectedReasons: ['Resposta incorreta'],
|
|
30
|
+
comment: 'A fonte citada não sustenta a conclusão.',
|
|
31
|
+
submitting: true,
|
|
32
|
+
localRating: null,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
closeMessageFeedback(state)
|
|
36
|
+
expect(state.openRating).toBe('negative')
|
|
37
|
+
|
|
38
|
+
finish?.({ ok: true })
|
|
39
|
+
await pending
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('preserves the draft and leaves the rating unchanged when persistence fails', async () => {
|
|
43
|
+
const state = createMessageFeedbackState()
|
|
44
|
+
openMessageFeedback(state, 'negative')
|
|
45
|
+
toggleMessageFeedbackReason(state, 'Resposta incorreta')
|
|
46
|
+
state.comment = 'A fonte citada não sustenta a conclusão.'
|
|
47
|
+
|
|
48
|
+
const saved = await submitMessageFeedback(
|
|
49
|
+
state,
|
|
50
|
+
'negative',
|
|
51
|
+
async () => ({ ok: false, message: 'Sua sessão expirou. Entre novamente e tente enviar o feedback.' }),
|
|
52
|
+
'Não foi possível enviar.',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(saved).toBe(false)
|
|
56
|
+
expect(state).toMatchObject({
|
|
57
|
+
openRating: 'negative',
|
|
58
|
+
selectedReasons: ['Resposta incorreta'],
|
|
59
|
+
comment: 'A fonte citada não sustenta a conclusão.',
|
|
60
|
+
submitting: false,
|
|
61
|
+
localRating: null,
|
|
62
|
+
error: 'Sua sessão expirou. Entre novamente e tente enviar o feedback.',
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('keeps the draft on an unexpected exception and succeeds on retry', async () => {
|
|
67
|
+
const state = createMessageFeedbackState()
|
|
68
|
+
openMessageFeedback(state, 'negative')
|
|
69
|
+
toggleMessageFeedbackReason(state, 'Outro')
|
|
70
|
+
state.comment = 'Detalhes que não podem ser perdidos.'
|
|
71
|
+
|
|
72
|
+
await submitMessageFeedback(
|
|
73
|
+
state,
|
|
74
|
+
'negative',
|
|
75
|
+
async () => { throw new Error('network failure') },
|
|
76
|
+
'Não foi possível enviar o feedback. Tente novamente.',
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
expect(state.error).toBe('Não foi possível enviar o feedback. Tente novamente.')
|
|
80
|
+
expect(state.comment).toBe('Detalhes que não podem ser perdidos.')
|
|
81
|
+
|
|
82
|
+
const saved = await submitMessageFeedback(
|
|
83
|
+
state,
|
|
84
|
+
'negative',
|
|
85
|
+
async payload => payload.comment === 'Detalhes que não podem ser perdidos.' ? { ok: true } : { ok: false },
|
|
86
|
+
'Não foi possível enviar o feedback. Tente novamente.',
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(saved).toBe(true)
|
|
90
|
+
expect(state).toMatchObject({
|
|
91
|
+
openRating: null,
|
|
92
|
+
selectedReasons: [],
|
|
93
|
+
comment: '',
|
|
94
|
+
submitting: false,
|
|
95
|
+
localRating: 'negative',
|
|
96
|
+
error: null,
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('message feedback drafts', () => {
|
|
102
|
+
it('drops the previous draft when the other rating is opened', () => {
|
|
103
|
+
const state = createMessageFeedbackState()
|
|
104
|
+
openMessageFeedback(state, 'positive')
|
|
105
|
+
toggleMessageFeedbackReason(state, 'Preciso')
|
|
106
|
+
state.comment = 'Rascunho do voto positivo.'
|
|
107
|
+
|
|
108
|
+
openMessageFeedback(state, 'negative')
|
|
109
|
+
|
|
110
|
+
expect(state).toMatchObject({
|
|
111
|
+
openRating: 'negative',
|
|
112
|
+
selectedReasons: [],
|
|
113
|
+
comment: '',
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('keeps the draft when the already open rating is reopened', () => {
|
|
118
|
+
const state = createMessageFeedbackState()
|
|
119
|
+
openMessageFeedback(state, 'negative')
|
|
120
|
+
toggleMessageFeedbackReason(state, 'Resposta incorreta')
|
|
121
|
+
|
|
122
|
+
openMessageFeedback(state, 'negative')
|
|
123
|
+
|
|
124
|
+
expect(state.selectedReasons).toEqual(['Resposta incorreta'])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('toggles a reason off on the second selection', () => {
|
|
128
|
+
const state = createMessageFeedbackState()
|
|
129
|
+
toggleMessageFeedbackReason(state, 'Resposta incorreta')
|
|
130
|
+
toggleMessageFeedbackReason(state, 'Fora do tema')
|
|
131
|
+
toggleMessageFeedbackReason(state, 'Resposta incorreta')
|
|
132
|
+
|
|
133
|
+
expect(state.selectedReasons).toEqual(['Fora do tema'])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('clears the draft and the error when the popover is closed', () => {
|
|
137
|
+
const state = createMessageFeedbackState()
|
|
138
|
+
openMessageFeedback(state, 'negative')
|
|
139
|
+
toggleMessageFeedbackReason(state, 'Resposta incorreta')
|
|
140
|
+
state.error = 'Falha anterior.'
|
|
141
|
+
|
|
142
|
+
closeMessageFeedback(state)
|
|
143
|
+
|
|
144
|
+
expect(state).toMatchObject({
|
|
145
|
+
openRating: null,
|
|
146
|
+
selectedReasons: [],
|
|
147
|
+
comment: '',
|
|
148
|
+
error: null,
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('negative feedback validation', () => {
|
|
154
|
+
it('accepts a bare thumbs-down when no reason is required', () => {
|
|
155
|
+
const state = createMessageFeedbackState()
|
|
156
|
+
|
|
157
|
+
expect(canSubmitNegativeFeedback(state, false, [])).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('requires a chip when chips are configured', () => {
|
|
161
|
+
const state = createMessageFeedbackState()
|
|
162
|
+
const reasons = ['Resposta incorreta']
|
|
163
|
+
|
|
164
|
+
expect(canSubmitNegativeFeedback(state, true, reasons)).toBe(false)
|
|
165
|
+
toggleMessageFeedbackReason(state, 'Resposta incorreta')
|
|
166
|
+
expect(canSubmitNegativeFeedback(state, true, reasons)).toBe(true)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('falls back to a non-blank comment when no chip is configured', () => {
|
|
170
|
+
const state = createMessageFeedbackState()
|
|
171
|
+
|
|
172
|
+
state.comment = ' '
|
|
173
|
+
expect(canSubmitNegativeFeedback(state, true, [])).toBe(false)
|
|
174
|
+
|
|
175
|
+
state.comment = 'A resposta ignorou a pergunta.'
|
|
176
|
+
expect(canSubmitNegativeFeedback(state, true, [])).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('message feedback timeout', () => {
|
|
181
|
+
it('gives up on a handler that never settles and offers a retry', async () => {
|
|
182
|
+
const state = createMessageFeedbackState()
|
|
183
|
+
openMessageFeedback(state, 'negative')
|
|
184
|
+
toggleMessageFeedbackReason(state, 'Resposta incorreta')
|
|
185
|
+
|
|
186
|
+
const saved = await submitMessageFeedback(
|
|
187
|
+
state,
|
|
188
|
+
'negative',
|
|
189
|
+
async () => await new Promise<never>(() => {}),
|
|
190
|
+
'Não foi possível enviar o feedback. Tente novamente.',
|
|
191
|
+
10,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
expect(saved).toBe(false)
|
|
195
|
+
expect(state).toMatchObject({
|
|
196
|
+
openRating: 'negative',
|
|
197
|
+
selectedReasons: ['Resposta incorreta'],
|
|
198
|
+
submitting: false,
|
|
199
|
+
localRating: null,
|
|
200
|
+
error: 'Não foi possível enviar o feedback. Tente novamente.',
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('ignores a second submission while the first is in flight', async () => {
|
|
205
|
+
const state = createMessageFeedbackState()
|
|
206
|
+
openMessageFeedback(state, 'positive')
|
|
207
|
+
|
|
208
|
+
let calls = 0
|
|
209
|
+
let finish: ((result: { ok: true }) => void) | undefined
|
|
210
|
+
const pending = submitMessageFeedback(
|
|
211
|
+
state,
|
|
212
|
+
'positive',
|
|
213
|
+
async () => {
|
|
214
|
+
calls += 1
|
|
215
|
+
return await new Promise<{ ok: true }>(resolve => finish = resolve)
|
|
216
|
+
},
|
|
217
|
+
'Não foi possível enviar.',
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
expect(await submitMessageFeedback(state, 'positive', async () => ({ ok: true }), 'Não foi possível enviar.')).toBe(false)
|
|
221
|
+
|
|
222
|
+
finish?.({ ok: true })
|
|
223
|
+
expect(await pending).toBe(true)
|
|
224
|
+
expect(calls).toBe(1)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export type FeedbackRating = 'positive' | 'negative'
|
|
2
|
+
|
|
3
|
+
export type FeedbackPayload = {
|
|
4
|
+
rating: FeedbackRating
|
|
5
|
+
reasons: string[]
|
|
6
|
+
comment: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type FeedbackSubmitResult
|
|
10
|
+
= | { ok: true }
|
|
11
|
+
| { ok: false, message?: string }
|
|
12
|
+
|
|
13
|
+
export type FeedbackSubmitHandler = (payload: FeedbackPayload) => Promise<FeedbackSubmitResult>
|
|
14
|
+
|
|
15
|
+
export type MessageFeedbackState = {
|
|
16
|
+
localRating: FeedbackRating | null
|
|
17
|
+
openRating: FeedbackRating | null
|
|
18
|
+
selectedReasons: string[]
|
|
19
|
+
comment: string
|
|
20
|
+
submitting: boolean
|
|
21
|
+
error: string | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createMessageFeedbackState(): MessageFeedbackState {
|
|
25
|
+
return {
|
|
26
|
+
localRating: null,
|
|
27
|
+
openRating: null,
|
|
28
|
+
selectedReasons: [],
|
|
29
|
+
comment: '',
|
|
30
|
+
submitting: false,
|
|
31
|
+
error: null,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resetDraft(state: MessageFeedbackState): void {
|
|
36
|
+
state.selectedReasons = []
|
|
37
|
+
state.comment = ''
|
|
38
|
+
state.error = null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function openMessageFeedback(state: MessageFeedbackState, rating: FeedbackRating): void {
|
|
42
|
+
if (state.submitting || state.openRating === rating)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
resetDraft(state)
|
|
46
|
+
state.openRating = rating
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function closeMessageFeedback(state: MessageFeedbackState): void {
|
|
50
|
+
if (state.submitting)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
state.openRating = null
|
|
54
|
+
resetDraft(state)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function toggleMessageFeedbackReason(state: MessageFeedbackState, reason: string): void {
|
|
58
|
+
state.selectedReasons = state.selectedReasons.includes(reason)
|
|
59
|
+
? state.selectedReasons.filter(item => item !== reason)
|
|
60
|
+
: [...state.selectedReasons, reason]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function canSubmitNegativeFeedback(
|
|
64
|
+
state: MessageFeedbackState,
|
|
65
|
+
requireReason: boolean,
|
|
66
|
+
negativeReasons: readonly string[],
|
|
67
|
+
): boolean {
|
|
68
|
+
if (!requireReason)
|
|
69
|
+
return true
|
|
70
|
+
if (negativeReasons.length > 0)
|
|
71
|
+
return state.selectedReasons.length > 0
|
|
72
|
+
return state.comment.trim().length > 0
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const DEFAULT_SUBMIT_TIMEOUT_MS = 15_000
|
|
76
|
+
|
|
77
|
+
export async function submitMessageFeedback(
|
|
78
|
+
state: MessageFeedbackState,
|
|
79
|
+
rating: FeedbackRating,
|
|
80
|
+
submit: FeedbackSubmitHandler,
|
|
81
|
+
fallbackErrorMessage: string,
|
|
82
|
+
timeoutMs: number = DEFAULT_SUBMIT_TIMEOUT_MS,
|
|
83
|
+
): Promise<boolean> {
|
|
84
|
+
if (state.submitting)
|
|
85
|
+
return false
|
|
86
|
+
|
|
87
|
+
const payload: FeedbackPayload = {
|
|
88
|
+
rating,
|
|
89
|
+
reasons: [...state.selectedReasons],
|
|
90
|
+
comment: state.comment.trim(),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
state.submitting = true
|
|
94
|
+
state.error = null
|
|
95
|
+
|
|
96
|
+
// Without this race a handler that never settles leaves the popover pinned
|
|
97
|
+
// open with every control disabled and no way for the user to escape.
|
|
98
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
99
|
+
const timeout = new Promise<FeedbackSubmitResult>((resolve) => {
|
|
100
|
+
timer = setTimeout(() => resolve({ ok: false }), timeoutMs)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const result = await Promise.race([submit(payload), timeout])
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
state.error = result.message || fallbackErrorMessage
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
state.localRating = rating
|
|
111
|
+
state.openRating = null
|
|
112
|
+
resetDraft(state)
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
state.error = fallbackErrorMessage
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
clearTimeout(timer)
|
|
121
|
+
state.submitting = false
|
|
122
|
+
}
|
|
123
|
+
}
|