tiptapify 0.3.5 → 0.4.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.
Files changed (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110770 -65623
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +37 -0
  6. package/package.json +3 -2
  7. package/src/components/ImageBubbleMenu.vue +5 -33
  8. package/src/components/MenuBubble.test.ts +120 -0
  9. package/src/components/MenuBubble.vue +124 -134
  10. package/src/components/MenuFloating.vue +48 -45
  11. package/src/components/Tiptapify.upload.test.ts +10 -0
  12. package/src/components/Tiptapify.vue +85 -45
  13. package/src/components/Toolbar/Index.vue +10 -0
  14. package/src/components/UI/BubbleRow.vue +41 -0
  15. package/src/components/UI/TiptapifyDialog.test.ts +37 -3
  16. package/src/components/UI/TiptapifyDialog.vue +15 -4
  17. package/src/components/editorExtensions.test.ts +9 -0
  18. package/src/components/editorExtensions.ts +21 -1
  19. package/src/composables/fakeUploadEndpoint.ts +65 -0
  20. package/src/composables/useTiptapifyConfig.ts +19 -0
  21. package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
  22. package/src/composables/useTiptapifyLinkPreview.ts +60 -0
  23. package/src/composables/useTiptapifyUpload.test.ts +96 -0
  24. package/src/composables/useTiptapifyUpload.ts +126 -0
  25. package/src/extensions/components/ai/Dialog.test.ts +335 -0
  26. package/src/extensions/components/ai/Dialog.vue +607 -310
  27. package/src/extensions/components/ai/backend.test.ts +1 -0
  28. package/src/extensions/components/ai/backend.ts +2 -1
  29. package/src/extensions/components/ai/context.test.ts +149 -0
  30. package/src/extensions/components/ai/context.ts +145 -0
  31. package/src/extensions/components/ai/diff.test.ts +65 -0
  32. package/src/extensions/components/ai/diff.ts +120 -0
  33. package/src/extensions/components/ai/errors.test.ts +45 -0
  34. package/src/extensions/components/ai/errors.ts +67 -0
  35. package/src/extensions/components/ai/result.test.ts +144 -0
  36. package/src/extensions/components/ai/result.ts +101 -0
  37. package/src/extensions/components/ai/thinking.test.ts +45 -0
  38. package/src/extensions/components/ai/thinking.ts +58 -0
  39. package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
  40. package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
  41. package/src/extensions/components/media/iframe/Button.vue +6 -7
  42. package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
  43. package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
  44. package/src/extensions/components/media/iframe/embed.test.ts +123 -0
  45. package/src/extensions/components/media/iframe/embed.ts +236 -0
  46. package/src/extensions/components/media/iframe/index.test.ts +123 -0
  47. package/src/extensions/components/media/iframe/index.ts +92 -10
  48. package/src/extensions/components/media/image/ImageDialog.vue +430 -132
  49. package/src/extensions/components/media/image/caption.test.ts +66 -1
  50. package/src/extensions/components/media/image/caption.ts +27 -1
  51. package/src/extensions/components/media/image/imageDialog.test.ts +210 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +117 -0
  53. package/src/extensions/components/media/image/index.ts +2 -1
  54. package/src/extensions/components/media/link/Button.test.ts +96 -0
  55. package/src/extensions/components/media/link/Button.vue +6 -2
  56. package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
  57. package/src/extensions/components/media/link/LinkDialog.vue +687 -61
  58. package/src/extensions/components/media/link/index.ts +111 -6
  59. package/src/extensions/components/media/link/link.marks.test.ts +89 -0
  60. package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
  61. package/src/extensions/components/media/link/linkDialog.ts +154 -0
  62. package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
  63. package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
  64. package/src/extensions/components/media/linkCard/index.test.ts +146 -0
  65. package/src/extensions/components/media/linkCard/index.ts +177 -0
  66. package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
  67. package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
  68. package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
  69. package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
  70. package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
  71. package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
  72. package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
  73. package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
  74. package/src/extensions/components/media/video/Button.vue +2 -1
  75. package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
  76. package/src/extensions/components/media/video/VideoDialog.vue +748 -59
  77. package/src/extensions/components/media/video/index.test.ts +154 -0
  78. package/src/extensions/components/media/video/index.ts +70 -5
  79. package/src/extensions/components/media/video/providers.test.ts +148 -0
  80. package/src/extensions/components/media/video/providers.ts +241 -0
  81. package/src/extensions/components/media/video/videoFile.test.ts +98 -0
  82. package/src/extensions/components/media/video/videoFile.ts +142 -0
  83. package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
  84. package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
  85. package/src/extensions/components/style/StyleColor.test.ts +255 -0
  86. package/src/extensions/components/style/StyleColor.vue +358 -208
  87. package/src/extensions/components/style/color/Button.vue +1 -3
  88. package/src/extensions/components/style/color/colors.test.ts +191 -0
  89. package/src/extensions/components/style/color/colors.ts +168 -0
  90. package/src/extensions/components/style/highlight/Button.vue +1 -3
  91. package/src/i18n/locales/ar.json +226 -23
  92. package/src/i18n/locales/bg.json +225 -22
  93. package/src/i18n/locales/ch.json +226 -23
  94. package/src/i18n/locales/cz.json +226 -23
  95. package/src/i18n/locales/de.json +226 -23
  96. package/src/i18n/locales/el.json +225 -22
  97. package/src/i18n/locales/en.json +225 -22
  98. package/src/i18n/locales/es.json +226 -23
  99. package/src/i18n/locales/et.json +225 -22
  100. package/src/i18n/locales/fi.json +226 -23
  101. package/src/i18n/locales/fr.json +226 -23
  102. package/src/i18n/locales/he.json +225 -22
  103. package/src/i18n/locales/hi.json +225 -22
  104. package/src/i18n/locales/hr.json +225 -22
  105. package/src/i18n/locales/hu.json +226 -23
  106. package/src/i18n/locales/id.json +225 -22
  107. package/src/i18n/locales/it.json +226 -23
  108. package/src/i18n/locales/ja.json +226 -23
  109. package/src/i18n/locales/ko.json +226 -23
  110. package/src/i18n/locales/la.json +226 -23
  111. package/src/i18n/locales/lt.json +226 -23
  112. package/src/i18n/locales/nl.json +226 -23
  113. package/src/i18n/locales/no.json +227 -24
  114. package/src/i18n/locales/pl.json +226 -23
  115. package/src/i18n/locales/pt-BR.json +225 -22
  116. package/src/i18n/locales/pt.json +226 -23
  117. package/src/i18n/locales/ro.json +225 -22
  118. package/src/i18n/locales/ru.json +227 -24
  119. package/src/i18n/locales/se.json +226 -23
  120. package/src/i18n/locales/sk.json +225 -22
  121. package/src/i18n/locales/sl.json +225 -22
  122. package/src/i18n/locales/sr.json +225 -22
  123. package/src/i18n/locales/th.json +226 -23
  124. package/src/i18n/locales/tr.json +226 -23
  125. package/src/i18n/locales/uk.json +227 -24
  126. package/src/i18n/locales/vi.json +226 -23
  127. package/src/index.ts +10 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +47 -2
  137. package/src/utils/validateHref.ts +47 -7
@@ -1,159 +1,235 @@
1
1
  <script lang="ts" setup>
2
2
 
3
+ import { aiErrorDetail, aiErrorKind } from '@tiptapify/extensions/components/ai/errors'
3
4
  import TiptapifyDialog from '@tiptapify/components/UI/TiptapifyDialog.vue'
5
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
4
6
  import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
5
7
  import {
6
8
  TiptapifyAiEditorContext,
7
9
  TiptapifyAiMode,
8
10
  TiptapifyAiOpenAiResponse,
9
- TiptapifyAiReasoningEffort,
10
11
  TiptapifyAiRequest,
11
12
  TiptapifyAiResponse,
12
13
  TiptapifyAiResolvedOptions,
13
14
  TiptapifyAiStream,
14
15
  TiptapifyEditor
15
16
  } from '@tiptapify/types/editor'
16
- import { computed, ComputedRef, inject, nextTick, Ref, ref, useTemplateRef, watch } from 'vue'
17
+ import { computed, ComputedRef, inject, nextTick, Ref, ref, shallowRef, useTemplateRef, watch, watchEffect } from 'vue'
17
18
  import * as mdi from '@mdi/js'
18
19
  import { ComposerTranslation } from 'vue-i18n'
19
20
  import { exceedsLimit } from './charLimit'
20
21
  import { DEFAULT_AI_INSTRUCTION } from './backend'
21
-
22
- const { variantBtn, variantField } = useTiptapifyConfig()
22
+ import { AiContextInfo, describeAiContext, selectedTextOf, textAround, wholeWordRange } from './context'
23
+ import { changePercent, diffWords, formatChangePercent } from './diff'
24
+ import {
25
+ aiActions,
26
+ AiApplyAction,
27
+ AiRange,
28
+ applyAiResult,
29
+ countedText,
30
+ parseAiResult,
31
+ plainText,
32
+ projectedCharacters,
33
+ renderAiResult
34
+ } from './result'
35
+ import { AiThinkingChoice, applyThinking, initialThinkingChoice, thinkingChoices } from './thinking'
36
+
37
+ const { variantBtn } = useTiptapifyConfig()
23
38
 
24
39
  const editor = inject('tiptapifyEditor') as Ref<TiptapifyEditor>
25
40
  const ai = inject('tiptapifyAi') as ComputedRef<TiptapifyAiResolvedOptions | false>
26
41
  const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
27
42
 
28
43
  const dialog = useTemplateRef('dialog')
44
+ const resultEl = useTemplateRef<HTMLElement>('resultEl')
45
+ const reasoningTextEl = useTemplateRef<HTMLElement>('reasoningTextEl')
46
+
29
47
  const prompt = ref('')
48
+ // The prompt of the shown result, for "Another version" after the prompt was edited
49
+ const lastPrompt = ref('')
30
50
  const result = ref('')
31
51
  const reasoning = ref('')
32
- const reasoningPanel = ref<number[]>([])
33
- const reasoningTextEl = ref<HTMLElement | null>(null)
52
+ const reasoningOpen = ref(false)
53
+ const reasoningStartedAt = ref<number | null>(null)
54
+ const reasoningEndedAt = ref<number | null>(null)
34
55
  const error = ref('')
35
56
  const loading = ref(false)
36
- const requestController = ref<AbortController | null>(null)
37
- const requestRange = ref<{ from: number, to: number } | null>(null)
57
+ const copied = ref(false)
58
+ const showChanges = ref(false)
59
+ const requestController = shallowRef<AbortController | null>(null)
60
+ const requestRange = ref<AiRange | null>(null)
61
+ // The selected text the result was generated from, for the change percent and the diff
62
+ const requestText = ref('')
38
63
  const requestMode = ref<TiptapifyAiMode>('insert')
39
- const thinkingEnabled = ref(false)
40
- const reasoningEffort = ref<'default' | TiptapifyAiReasoningEffort | null>(null)
64
+ const context = ref<AiContextInfo>({ kind: 'cursor', paragraphs: 0, characters: 0, excerpt: '' })
65
+ const thinkingChoice = ref<AiThinkingChoice | null>(null)
66
+
67
+ const aiProvider = computed(() => ai?.value ? ai.value.aiProvider : undefined)
68
+ const promptExamples = computed(() => ai?.value ? ai.value.promptExamples : [])
69
+ const showReasoning = computed(() => ai?.value?.showReasoning === true)
70
+ const thinkingOptions = computed(() => thinkingChoices(ai?.value || {}))
71
+ // With effort options the control speaks of effort ("Effort: high"), otherwise of thinking on/off
72
+ const effortMode = computed(() => thinkingOptions.value.length > 2)
73
+ const isGenerateDisabled = computed(() => !aiProvider.value || !prompt.value.trim() || loading.value)
41
74
 
42
- watch(loading, (value) => {
43
- if (value) {
44
- reasoningPanel.value = [0]
75
+ const contextIcon = computed(() => ({
76
+ selection: mdi.mdiSelect,
77
+ document: mdi.mdiTextBoxOutline,
78
+ cursor: mdi.mdiCursorText,
79
+ })[context.value.kind])
80
+ const contextLabel = computed(() => {
81
+ const { kind, paragraphs, characters } = context.value
82
+ if (kind !== 'selection') {
83
+ return t(`ai.context.${kind}`)
45
84
  }
46
- })
47
85
 
48
- watch(reasoning, () => {
49
- nextTick(() => {
50
- const element = reasoningTextEl.value
51
- if (element) {
52
- element.scrollTop = element.scrollHeight
53
- }
86
+ return t('ai.context.selection', {
87
+ paragraphs: t('ai.context.paragraphs', paragraphs),
88
+ characters: t('ai.context.characters', characters),
54
89
  })
55
90
  })
56
91
 
57
- const aiProvider = computed(() => ai?.value ? ai.value.aiProvider : undefined)
58
- const promptExamples = computed(() => ai?.value ? ai.value.promptExamples : [])
59
- const reasoningEffortOptions = computed<TiptapifyAiReasoningEffort[]>(() => {
60
- const configured = ai?.value?.reasoningEffort
61
- if (!configured || !Array.isArray(configured.options)) {
62
- return []
63
- }
92
+ const actions = computed(() => aiActions(context.value.kind === 'selection', ai?.value ? ai.value.mode : undefined))
64
93
 
65
- return configured.options.filter(option => option !== 'default')
66
- })
67
- const reasoningEffortItems = computed<Array<'default' | TiptapifyAiReasoningEffort>>(() => {
68
- return ['default', ...reasoningEffortOptions.value]
69
- })
70
- const reasoningEffortSupported = computed(() => reasoningEffortOptions.value.length > 0)
71
- const thinkingSupported = computed(() => ai?.value?.thinking === true)
72
- const showReasoning = computed(() => ai?.value?.showReasoning === true)
73
- const showReasoningEffort = computed(() => thinkingSupported.value && reasoningEffortSupported.value && thinkingEnabled.value)
74
- const useReasoningEffort = computed(() => {
75
- return thinkingSupported.value
76
- && thinkingEnabled.value
77
- && reasoningEffortSupported.value
78
- && reasoningEffort.value !== null
79
- && reasoningEffort.value !== 'default'
94
+ const resultFragment = computed(() => parseAiResult(result.value, editor.value.schema))
95
+ const resultText = computed(() => countedText(resultFragment.value))
96
+ const changes = computed(() => diffWords(requestText.value, plainText(resultFragment.value)))
97
+ const percent = computed(() => {
98
+ const value = loading.value ? null : changePercent(requestText.value, plainText(resultFragment.value))
99
+
100
+ return value === null ? '' : formatChangePercent(value)
80
101
  })
81
- const isGenerateDisabled = computed(() => !aiProvider.value || !prompt.value.trim() || loading.value)
102
+
82
103
  const characterCountLimit = computed(() => {
83
104
  const limit = getCharacterCountExtension()?.options?.limit
84
105
  return typeof limit === 'number' ? limit : null
85
106
  })
86
- const limitExceeded = computed(() => {
107
+ const counter = computed(() => {
108
+ if (characterCountLimit.value === null) {
109
+ return t('ai.context.characters', resultText.value.length)
110
+ }
111
+
112
+ const projected = projectedCharacters(editor.value.state.doc, resultText.value, actions.value[0], requestRange.value)
113
+
114
+ return `${projected} / ${characterCountLimit.value}`
115
+ })
116
+
117
+ // The CharacterCount extension rejects a result over its limit, so that action stays disabled; replacing frees the selected characters
118
+ function exceeds(action: AiApplyAction) {
87
119
  const limit = characterCountLimit.value
88
120
  if (limit === null || !result.value) {
89
121
  return false
90
122
  }
91
123
 
92
- return exceedsLimit(result.value, {
124
+ return exceedsLimit(resultText.value, {
93
125
  doc: editor.value.state.doc,
94
126
  limit,
95
- mode: requestMode.value,
127
+ mode: action === 'replace' ? 'replace' : 'insert',
96
128
  range: requestRange.value,
97
129
  })
130
+ }
131
+
132
+ function canApply(action: AiApplyAction) {
133
+ return Boolean(result.value.trim()) && !loading.value && !exceeds(action)
134
+ }
135
+
136
+ const primaryExceeded = computed(() => exceeds(actions.value[0]))
137
+
138
+ // Loading shows in the result itself, not in the status line
139
+ const status = computed(() => {
140
+ if (!aiProvider.value) {
141
+ return { type: 'warning', text: t('ai.unavailable') }
142
+ }
143
+ if (error.value) {
144
+ return { type: 'error', text: error.value }
145
+ }
146
+ if (primaryExceeded.value) {
147
+ return { type: 'error', text: t('ai.limit_exceeded') }
148
+ }
149
+
150
+ return null
98
151
  })
99
- // A result that exceeds the character limit cannot be inserted (the
100
- // CharacterCount extension rejects it), so the apply action (and its
101
- // alternative modes) is disabled until the result is shortened.
102
- const isApplyDisabled = computed(() => !result.value.trim() || loading.value || limitExceeded.value)
103
- const actionLabel = computed(() => t(`ai.${requestMode.value}`))
104
- const modeOptions: TiptapifyAiMode[] = ['insert', 'replace', 'append']
105
- const alternativeModes = computed(() => modeOptions.filter(mode => mode !== requestMode.value))
106
-
107
- function applyInMode(mode: TiptapifyAiMode) {
108
- if (isApplyDisabled.value) {
109
- return
152
+
153
+ const reasoningLabel = computed(() => {
154
+ if (loading.value && reasoningEndedAt.value === null) {
155
+ return t('ai.thinking_now')
110
156
  }
111
157
 
112
- requestMode.value = mode
113
- apply()
114
- }
158
+ const started = reasoningStartedAt.value ?? 0
159
+ const seconds = Math.max(1, Math.round(((reasoningEndedAt.value ?? started) - started) / 1000))
160
+
161
+ return t('ai.thought_for', { seconds })
162
+ })
163
+
164
+ watch(reasoning, () => {
165
+ nextTick(() => {
166
+ const element = reasoningTextEl.value
167
+ if (element) {
168
+ element.scrollTop = element.scrollHeight
169
+ }
170
+ })
171
+ })
172
+
173
+ watchEffect(() => {
174
+ if (resultEl.value) {
175
+ renderAiResult(resultFragment.value, editor.value.schema, resultEl.value)
176
+ }
177
+ }, { flush: 'post' })
115
178
 
116
179
  defineExpose({ showDialog })
117
180
 
118
- function resetReasoningEffort() {
119
- const configured = ai?.value?.reasoningEffort
120
- const options = reasoningEffortOptions.value
181
+ function actionLabel(action: AiApplyAction) {
182
+ return t({ replace: 'ai.replace_selection', below: 'ai.insert_below', insert: 'ai.insert', append: 'ai.append' }[action])
183
+ }
121
184
 
122
- if (options.length === 0) {
123
- reasoningEffort.value = null
124
- return
185
+ // A menu item ("High") and the control's own label ("Effort: high"): with effort options
186
+ // thinking on is the provider's default effort
187
+ function choiceLabel(choice: AiThinkingChoice, group: 'states' | 'label' = 'states') {
188
+ if (choice === 'off' || (choice === 'on' && !effortMode.value)) {
189
+ return t(`ai.thinking_${group}.${choice}`)
125
190
  }
126
191
 
127
- reasoningEffort.value = configured?.default && options.includes(configured.default)
128
- ? configured.default
129
- : 'default'
192
+ return t(`ai.effort_${group === 'states' ? 'levels' : 'label'}.${choice === 'on' ? 'default' : choice}`)
130
193
  }
131
194
 
132
- function showDialog() {
133
- prompt.value = ai?.value && ai.value.defaultPrompt ? ai.value.defaultPrompt : ''
195
+ function resetResult() {
134
196
  result.value = ''
135
197
  reasoning.value = ''
198
+ reasoningOpen.value = false
199
+ reasoningStartedAt.value = null
200
+ reasoningEndedAt.value = null
136
201
  error.value = ''
137
- requestRange.value = null
138
- const { from, to } = editor.value.state.selection
139
- requestMode.value = ai.value?.mode ?? (getSelectedText(from, to) ? 'replace' : 'insert')
140
- thinkingEnabled.value = true
141
- resetReasoningEffort()
142
-
143
- dialog.value?.open()
202
+ copied.value = false
203
+ showChanges.value = false
144
204
  }
145
205
 
146
- function close() {
206
+ function reset() {
147
207
  requestController.value?.abort()
148
208
  requestController.value = null
209
+ loading.value = false
149
210
 
150
211
  prompt.value = ''
151
- result.value = ''
152
- reasoning.value = ''
153
- error.value = ''
212
+ lastPrompt.value = ''
154
213
  requestRange.value = null
214
+ requestText.value = ''
155
215
  requestMode.value = 'insert'
216
+ resetResult()
217
+ }
156
218
 
219
+ function showDialog() {
220
+ reset()
221
+ prompt.value = ai?.value && ai.value.defaultPrompt ? ai.value.defaultPrompt : ''
222
+
223
+ const { doc, selection } = editor.value.state
224
+ const { from, to } = wholeWordRange(doc, selection.from, selection.to)
225
+ context.value = describeAiContext(doc, from, to)
226
+ requestMode.value = (ai?.value ? ai.value.mode : undefined) ?? (selectedTextOf(doc, from, to) ? 'replace' : 'insert')
227
+ thinkingChoice.value = initialThinkingChoice(ai?.value || {})
228
+
229
+ dialog.value?.open()
230
+ }
231
+
232
+ function cancel() {
157
233
  dialog.value?.close()
158
234
  }
159
235
 
@@ -165,20 +241,20 @@ function applyExample(examplePrompt: string) {
165
241
  prompt.value = examplePrompt
166
242
  }
167
243
 
168
- function getSelectedText(from: number, to: number) {
169
- if (from === to) {
170
- return ''
244
+ // Enter sends, Shift+Enter is a new line (an IME composition keeps its Enter)
245
+ function onPromptKeydown(event: KeyboardEvent) {
246
+ if (event.key !== 'Enter' || event.shiftKey || event.isComposing) {
247
+ return
171
248
  }
172
249
 
173
- return editor.value.state.doc.textBetween(from, to, '\n')
250
+ event.preventDefault()
251
+ generate()
174
252
  }
175
253
 
176
254
  function buildDefaultMessages(context: TiptapifyAiEditorContext) {
177
255
  const systemPrompt = ai.value && ai.value.systemPrompt
178
256
  ? ai.value.systemPrompt
179
257
  : DEFAULT_AI_INSTRUCTION
180
- const contextText = context.selectedText || context.text
181
- const contextLabel = context.selectedText ? 'Selected text' : 'Editor text'
182
258
 
183
259
  return [
184
260
  {
@@ -187,13 +263,28 @@ function buildDefaultMessages(context: TiptapifyAiEditorContext) {
187
263
  },
188
264
  {
189
265
  role: 'user' as const,
190
- content: contextText
191
- ? `${contextLabel}:\n${contextText}\n\nUser request:\n${context.prompt}`
192
- : context.prompt,
266
+ content: defaultUserMessage(context),
193
267
  },
194
268
  ]
195
269
  }
196
270
 
271
+ // A selection goes with the text around it and the rule to answer for the selection alone: given only a
272
+ // fragment, a model takes it for the whole text and completes it, and the completion lands next to the rest
273
+ function defaultUserMessage(context: TiptapifyAiEditorContext): string {
274
+ if (!context.selectedText) {
275
+ return context.text ? `Editor text:\n${context.text}\n\nUser request:\n${context.prompt}` : context.prompt
276
+ }
277
+
278
+ return [
279
+ context.textBefore ? `Text before the selection:\n${context.textBefore}` : '',
280
+ `Selected text:\n${context.selectedText}`,
281
+ context.textAfter ? `Text after the selection:\n${context.textAfter}` : '',
282
+ 'Work on the selected text only and answer with its new version alone. The text before and after it stays '
283
+ + 'in the document as it is: do not repeat it and do not continue the selection into it.',
284
+ `User request:\n${context.prompt}`,
285
+ ].filter(Boolean).join('\n\n')
286
+ }
287
+
197
288
  function buildOpenAiRequest(context: TiptapifyAiEditorContext): TiptapifyAiRequest {
198
289
  const options = ai.value || {}
199
290
  const chatCompletionOptions = options.chatCompletionOptions ?? {}
@@ -212,16 +303,7 @@ function buildOpenAiRequest(context: TiptapifyAiEditorContext): TiptapifyAiReque
212
303
  request.temperature = options.temperature
213
304
  }
214
305
 
215
- if (useReasoningEffort.value) {
216
- delete request.enable_thinking
217
- request.reasoning_effort = reasoningEffort.value
218
- } else {
219
- delete request.reasoning_effort
220
-
221
- request.enable_thinking = thinkingSupported.value && thinkingEnabled.value
222
- }
223
-
224
- return request
306
+ return applyThinking(request, thinkingChoice.value)
225
307
  }
226
308
 
227
309
  function isTiptapifyAiResponse(response: unknown): response is TiptapifyAiResponse {
@@ -246,26 +328,48 @@ function getResponseContent(response: TiptapifyAiResponse | TiptapifyAiOpenAiRes
246
328
  return content.trimStart()
247
329
  }
248
330
 
249
- async function generate() {
250
- if (isGenerateDisabled.value || !aiProvider.value) {
331
+ // Thinking ends with the first chunk of the answer (or with the request)
332
+ function markThoughtEnd() {
333
+ if (reasoningStartedAt.value !== null && reasoningEndedAt.value === null) {
334
+ reasoningEndedAt.value = Date.now()
335
+ }
336
+ }
337
+
338
+
339
+ function errorMessage(err: unknown): string {
340
+ const kind = aiErrorKind(err)
341
+ const detail = aiErrorDetail(err)
342
+
343
+ if (kind === 'other') {
344
+ return detail || t('ai.error')
345
+ }
346
+
347
+ return detail ? `${t(`ai.errors.${kind}`)} ${t('ai.errors.service_says')} ${detail}` : t(`ai.errors.${kind}`)
348
+ }
349
+ async function generate(promptText = prompt.value.trim()) {
350
+ if (!aiProvider.value || loading.value || !promptText) {
251
351
  return
252
352
  }
253
353
 
254
- error.value = ''
255
- result.value = ''
256
- reasoning.value = ''
354
+ resetResult()
257
355
  loading.value = true
356
+ lastPrompt.value = promptText
258
357
 
259
358
  const controller = new AbortController()
260
359
  requestController.value = controller
261
360
 
262
- const { from, to } = editor.value.state.selection
263
- const selectedText = getSelectedText(from, to)
361
+ const { doc, selection } = editor.value.state
362
+ const { from, to } = wholeWordRange(doc, selection.from, selection.to)
363
+ const selectedText = selectedTextOf(doc, from, to)
264
364
  requestRange.value = selectedText ? { from, to } : null
365
+ requestText.value = selectedText
265
366
 
367
+ const around = textAround(doc, from, to)
266
368
  const context: TiptapifyAiEditorContext = {
267
- prompt: prompt.value.trim(),
369
+ prompt: promptText,
268
370
  selectedText,
371
+ textBefore: around.before,
372
+ textAfter: around.after,
269
373
  text: selectedText || editor.value.getText(),
270
374
  html: editor.value.getHTML(),
271
375
  json: editor.value.getJSON(),
@@ -277,11 +381,13 @@ async function generate() {
277
381
  signal: controller.signal,
278
382
  onChunk: (chunk: string) => {
279
383
  if (chunk && !controller.signal.aborted) {
384
+ markThoughtEnd()
280
385
  result.value += chunk
281
386
  }
282
387
  },
283
388
  onReasoning: (chunk: string) => {
284
389
  if (chunk && !controller.signal.aborted) {
390
+ reasoningStartedAt.value ??= Date.now()
285
391
  reasoning.value += chunk
286
392
  }
287
393
  },
@@ -296,10 +402,15 @@ async function generate() {
296
402
  }
297
403
  } catch (err) {
298
404
  if (!controller.signal.aborted) {
299
- error.value = err instanceof Error ? err.message : t('ai.error')
405
+ error.value = errorMessage(err)
300
406
  }
301
407
  } finally {
302
- loading.value = false
408
+ // A request the dialog already let go of (closed meanwhile) leaves the state alone
409
+ if (requestController.value === controller) {
410
+ loading.value = false
411
+ requestController.value = null
412
+ markThoughtEnd()
413
+ }
303
414
  }
304
415
  }
305
416
 
@@ -307,262 +418,448 @@ function getCharacterCountExtension() {
307
418
  return editor.value.options.extensions?.find(item => item.name === 'characterCount')
308
419
  }
309
420
 
310
- function apply() {
311
- if (isApplyDisabled.value) {
421
+ async function copyResult() {
422
+ const container = document.createElement('div')
423
+ renderAiResult(resultFragment.value, editor.value.schema, container)
424
+ const text = plainText(resultFragment.value)
425
+
426
+ try {
427
+ if (typeof ClipboardItem === 'undefined') {
428
+ await navigator.clipboard.writeText(text)
429
+ } else {
430
+ await navigator.clipboard.write([new ClipboardItem({
431
+ 'text/html': new Blob([container.innerHTML], { type: 'text/html' }),
432
+ 'text/plain': new Blob([text], { type: 'text/plain' }),
433
+ })])
434
+ }
435
+ } catch {
436
+ // No clipboard access (an insecure page or a denied permission): the button stays as it was
312
437
  return
313
438
  }
314
439
 
315
- const chain = editor.value.chain().focus()
316
- if (requestMode.value === 'replace' && requestRange.value) {
317
- chain.deleteRange(requestRange.value)
318
- }
440
+ copied.value = true
441
+ setTimeout(() => {
442
+ copied.value = false
443
+ }, 1500)
444
+ }
319
445
 
320
- if (requestMode.value === 'append') {
321
- chain.insertContentAt(editor.value.state.doc.content.size, result.value).run()
322
- } else {
323
- chain.insertContent(result.value).run()
446
+ function apply(action: AiApplyAction) {
447
+ if (!canApply(action)) {
448
+ return
324
449
  }
325
450
 
326
- close()
451
+ applyAiResult(editor.value, action, result.value, requestRange.value)
452
+ dialog.value?.close()
327
453
  }
328
454
 
329
455
  </script>
330
456
 
331
457
  <template>
332
- <TiptapifyDialog ref="dialog" module="ai" :title="t('ai.title')" :max-width="720" @close-dialog="close">
458
+ <TiptapifyDialog ref="dialog" module="ai" :title="t('ai.title')" :max-width="640" @submit="apply(actions[0])" @closed="reset">
459
+ <template #title>
460
+ <VIcon size="20" class="mr-2" :icon="`mdiSvg:${mdi.mdiCreation}`" />{{ t('ai.title') }}
461
+ </template>
462
+
333
463
  <template #content>
334
- <VCardText>
335
- <VAlert v-if="!aiProvider" type="warning" variant="tonal" density="compact" class="mb-4">
336
- {{ t('ai.unavailable') }}
337
- </VAlert>
338
-
339
- <div v-if="promptExamples.length" class="mb-4">
340
- <VLabel class="mb-2 d-block">
341
- {{ t('ai.quick_prompts') }}
342
- </VLabel>
343
- <div class="d-flex flex-wrap ga-2">
344
- <VChip
345
- v-for="example in promptExamples"
346
- :key="`${example.title}:${example.prompt}`"
347
- variant="tonal"
348
- :disabled="loading || !aiProvider"
349
- @click="applyExample(example.prompt)"
350
- >
351
- {{ example.title }}
352
- </VChip>
353
- </div>
464
+ <VCardText class="ai-dialog">
465
+ <div class="ai-context">
466
+ <VIcon size="16" :icon="`mdiSvg:${contextIcon}`" />
467
+ <span class="ai-context__label">{{ contextLabel }}</span>
468
+ <span v-if="context.excerpt" class="ai-context__excerpt">
469
+ {{ t('ai.context.excerpt', { text: context.excerpt }) }}
470
+ </span>
354
471
  </div>
355
472
 
356
- <VTextarea
357
- v-model="prompt"
358
- :label="t('ai.prompt')"
359
- :variant="variantField"
360
- :disabled="loading || !aiProvider"
361
- rows="4"
362
- auto-grow
363
- autofocus
364
- />
365
-
366
- <div v-if="thinkingSupported" class="ai-thinking-toggle d-flex align-center mb-4">
367
- <VIcon size="x-small" class="mr-2" :icon="`mdiSvg:${mdi.mdiBrain}`" />
368
- <VLabel>{{ t('ai.thinking') }}</VLabel>
369
- <VSwitch
370
- v-model="thinkingEnabled"
371
- :disabled="loading || useReasoningEffort"
372
- density="compact"
373
- hide-details
374
- color="primary"
375
- class="ai-thinking-toggle__switch"
376
- />
473
+ <div v-if="promptExamples.length" class="ai-examples" role="group" :aria-label="t('ai.quick_prompts')">
474
+ <VChip
475
+ v-for="example in promptExamples"
476
+ :key="`${example.title}:${example.prompt}`"
477
+ size="small"
478
+ variant="tonal"
479
+ :disabled="loading || !aiProvider"
480
+ @click="applyExample(example.prompt)"
481
+ >
482
+ {{ example.title }}
483
+ </VChip>
377
484
  </div>
378
485
 
379
- <div v-if="showReasoningEffort" class="ai-reasoning-effort d-flex align-center mb-4">
380
- <VIcon size="x-small" class="mr-2" :icon="`mdiSvg:${mdi.mdiGauge}`" />
381
- <VLabel>{{ t('ai.reasoning_effort') }}</VLabel>
382
- <VSelect
383
- v-model="reasoningEffort"
384
- :items="reasoningEffortItems"
385
- :disabled="loading"
386
- :variant="variantField"
486
+ <div class="ai-prompt" :class="{ 'ai-prompt--disabled': !aiProvider }">
487
+ <VTextarea
488
+ v-model="prompt"
489
+ class="ai-prompt__input"
490
+ :aria-label="t('ai.prompt')"
491
+ :placeholder="t('ai.prompt_placeholder')"
492
+ :disabled="loading || !aiProvider"
493
+ variant="plain"
387
494
  density="compact"
495
+ rows="2"
496
+ max-rows="8"
388
497
  hide-details
389
- class="ai-reasoning-effort__select"
498
+ auto-grow
499
+ autofocus
500
+ @keydown="onPromptKeydown"
390
501
  />
502
+
503
+ <div class="ai-prompt__bar">
504
+ <VMenu v-if="thinkingChoice" location="top start">
505
+ <template #activator="{ props }">
506
+ <VBtn
507
+ v-bind="props"
508
+ class="ai-thinking"
509
+ size="small"
510
+ variant="text"
511
+ :disabled="loading || !aiProvider"
512
+ :prepend-icon="`mdiSvg:${mdi.mdiBrain}`"
513
+ :append-icon="`mdiSvg:${mdi.mdiChevronDown}`"
514
+ >
515
+ {{ choiceLabel(thinkingChoice, 'label') }}
516
+ </VBtn>
517
+ </template>
518
+ <VList density="compact">
519
+ <VListSubheader>{{ effortMode ? t('ai.reasoning_effort') : t('ai.thinking') }}</VListSubheader>
520
+ <VListItem
521
+ v-for="choice in thinkingOptions"
522
+ :key="choice"
523
+ :active="choice === thinkingChoice"
524
+ color="primary"
525
+ @click="thinkingChoice = choice"
526
+ >
527
+ <VListItemTitle>{{ choiceLabel(choice) }}</VListItemTitle>
528
+ </VListItem>
529
+ </VList>
530
+ </VMenu>
531
+
532
+ <VSpacer />
533
+
534
+ <span class="ai-prompt__hint">{{ t('ai.send_hint') }}</span>
535
+
536
+ <VBtn
537
+ v-if="loading"
538
+ class="ai-send ai-send--stop"
539
+ icon
540
+ size="32"
541
+ variant="tonal"
542
+ :aria-label="t('ai.stop')"
543
+ @click="stop"
544
+ >
545
+ <VIcon size="18" :icon="`mdiSvg:${mdi.mdiStop}`" />
546
+ <Tooltip :label="t('ai.stop')" />
547
+ </VBtn>
548
+ <VBtn
549
+ v-else
550
+ class="ai-send"
551
+ icon
552
+ size="32"
553
+ color="primary"
554
+ variant="flat"
555
+ :disabled="isGenerateDisabled"
556
+ :aria-label="t('ai.generate')"
557
+ @click="generate()"
558
+ >
559
+ <VIcon size="18" :icon="`mdiSvg:${mdi.mdiArrowUp}`" />
560
+ <Tooltip :label="t('ai.generate')" />
561
+ </VBtn>
562
+ </div>
563
+ </div>
564
+
565
+ <div v-if="status" class="ai-status" :class="`ai-status--${status.type}`" role="alert">
566
+ <VIcon size="16" :icon="`mdiSvg:${mdi.mdiAlertCircleOutline}`" />
567
+ <span>{{ status.text }}</span>
568
+ </div>
569
+
570
+ <div v-if="showReasoning && reasoning" class="ai-reasoning">
571
+ <button
572
+ type="button"
573
+ class="ai-reasoning__toggle"
574
+ :aria-expanded="reasoningOpen"
575
+ @click="reasoningOpen = !reasoningOpen"
576
+ >
577
+ <VIcon size="16" :icon="`mdiSvg:${mdi.mdiBrain}`" />
578
+ <span>{{ reasoningLabel }}</span>
579
+ <VIcon size="16" :icon="`mdiSvg:${reasoningOpen ? mdi.mdiChevronDown : mdi.mdiChevronRight}`" />
580
+ </button>
581
+ <pre v-if="reasoningOpen" ref="reasoningTextEl" class="ai-reasoning__text">{{ reasoning }}</pre>
391
582
  </div>
392
583
 
393
- <VExpansionPanels v-if="showReasoning && reasoning" v-model="reasoningPanel" variant="default" class="ai-reasoning mb-4">
394
- <VExpansionPanel :value="0">
395
- <template #title>
396
- <VIcon size="x-small" :icon="`mdiSvg:${mdi.mdiBrain}`" />
397
- <span class="ml-2">{{ t('ai.thinking') }}</span>
398
- </template>
399
- <template #text>
400
- <pre ref="reasoningTextEl" class="ai-reasoning__text">{{ reasoning }}</pre>
401
- </template>
402
- </VExpansionPanel>
403
- </VExpansionPanels>
404
-
405
- <VTextarea
406
- v-model="result"
407
- :label="t('ai.result')"
408
- :variant="variantField"
409
- :loading="loading && !result"
410
- :readonly="loading"
411
- rows="5"
412
- auto-grow
413
- />
414
-
415
- <VLabel
416
- v-if="characterCountLimit !== null && result"
417
- class="ai-char-count d-block"
418
- :class="{ 'ai-char-count--exceeded': limitExceeded }"
419
- >
420
- {{ result.length }} / {{ characterCountLimit }}
421
- </VLabel>
422
-
423
- <VAlert
424
- v-if="limitExceeded"
425
- type="error"
426
- variant="tonal"
427
- density="compact"
428
- class="ai-limit-alert mt-4"
429
- >
430
- {{ t('ai.limit_exceeded') }}
431
- </VAlert>
432
-
433
- <VAlert v-if="loading" type="info" variant="tonal" density="compact" class="mt-4">
434
- {{ t('ai.loading') }}
435
- </VAlert>
436
-
437
- <VAlert v-if="error" type="error" variant="tonal" density="compact" class="mt-4">
438
- {{ error }}
439
- </VAlert>
584
+ <div v-if="result || loading" class="ai-result" :aria-busy="loading">
585
+ <VProgressLinear v-if="loading" class="ai-result__progress" color="primary" height="2" indeterminate />
586
+
587
+ <div v-if="!result" class="ai-result__placeholder">
588
+ {{ t('ai.loading') }}
589
+ </div>
590
+ <div v-else-if="showChanges" class="ai-result__content ai-diff">
591
+ <span v-for="(segment, index) in changes" :key="index" :class="`ai-diff__${segment.type}`">{{ segment.text }}</span>
592
+ </div>
593
+ <div v-else ref="resultEl" class="ai-result__content" role="region" :aria-label="t('ai.result')" />
594
+
595
+ <div v-if="result" class="ai-result__footer">
596
+ <span v-if="percent" class="ai-result__meta">{{ t('ai.change_vs_original', { percent }) }}</span>
597
+ <span class="ai-result__meta ai-result__counter" :class="{ 'ai-result__meta--exceeded': primaryExceeded }">{{ counter }}</span>
598
+
599
+ <VSpacer />
600
+
601
+ <VBtn
602
+ v-if="requestText"
603
+ size="small"
604
+ variant="text"
605
+ :active="showChanges"
606
+ :disabled="loading"
607
+ :prepend-icon="`mdiSvg:${mdi.mdiCompareHorizontal}`"
608
+ @click="showChanges = !showChanges"
609
+ >
610
+ {{ t('ai.changes') }}
611
+ </VBtn>
612
+ <VBtn
613
+ size="small"
614
+ variant="text"
615
+ :disabled="loading || !aiProvider"
616
+ :prepend-icon="`mdiSvg:${mdi.mdiRefresh}`"
617
+ @click="generate(lastPrompt)"
618
+ >
619
+ {{ t('ai.another_version') }}
620
+ </VBtn>
621
+ <VBtn
622
+ size="small"
623
+ variant="text"
624
+ :disabled="loading"
625
+ :prepend-icon="`mdiSvg:${copied ? mdi.mdiCheck : mdi.mdiContentCopy}`"
626
+ @click="copyResult"
627
+ >
628
+ {{ copied ? t('ai.copied') : t('ai.copy') }}
629
+ </VBtn>
630
+ </div>
631
+ </div>
440
632
  </VCardText>
441
633
  </template>
442
634
 
443
635
  <template #actions>
444
636
  <VCardActions>
445
- <VSpacer />
446
- <VBtn :variant="variantBtn" @click="close">
447
- {{ t('dialog.close') }}
637
+ <VBtn :variant="variantBtn" @click="cancel">
638
+ {{ t('dialog.cancel') }}
448
639
  </VBtn>
449
- <VBtn color="primary" :variant="variantBtn" :disabled="isGenerateDisabled" :loading="loading" @click="generate">
450
- {{ t('ai.generate') }}
640
+
641
+ <VSpacer />
642
+
643
+ <VBtn :variant="variantBtn" :disabled="!canApply(actions[1])" @click="apply(actions[1])">
644
+ {{ actionLabel(actions[1]) }}
451
645
  </VBtn>
452
- <VBtn v-if="loading" color="warning" :variant="variantBtn" @click="stop">
453
- {{ t('ai.stop') }}
646
+ <VBtn type="submit" color="primary" :variant="variantBtn" :disabled="!canApply(actions[0])">
647
+ {{ actionLabel(actions[0]) }}
454
648
  </VBtn>
455
-
456
- <VBtnGroup
457
- :class="{ 'tiptapify-btn-group--elevated': variantBtn === 'elevated' && !isApplyDisabled }"
458
- density="compact"
459
- :variant="variantBtn"
460
- divided
461
- >
462
- <VBtn
463
- color="primary"
464
- :variant="variantBtn"
465
- :disabled="isApplyDisabled"
466
- @click="apply"
467
- >
468
- {{ t('ai.insert') }}
469
- </VBtn>
470
-
471
- <VMenu>
472
- <template #activator="{ props }">
473
- <VBtn
474
- v-bind="{ ...props, minWidth: 30 }"
475
- color="primary"
476
- :disabled="isApplyDisabled"
477
- :variant="variantBtn"
478
- >
479
- <VIcon :icon="`mdiSvg:${mdi.mdiDotsVertical}`" />
480
- </VBtn>
481
- </template>
482
- <VList>
483
- <VListItem
484
- v-for="mode in alternativeModes"
485
- :key="mode"
486
- @click="applyInMode(mode)"
487
- >
488
- <VListItemTitle>{{ t(`ai.${mode}`) }}</VListItemTitle>
489
- </VListItem>
490
- </VList>
491
- </VMenu>
492
- </VBtnGroup>
493
649
  </VCardActions>
494
650
  </template>
495
651
  </TiptapifyDialog>
496
652
  </template>
497
653
 
498
654
  <style lang="scss" scoped>
499
- .tiptapify-btn-group--elevated {
500
- box-shadow:
501
- 0 3px 1px -2px var(--v-shadow-key-umbra-opacity, rgba(0, 0, 0, 0.2)),
502
- 0 2px 2px 0 var(--v-shadow-key-penumbra-opacity, rgba(0, 0, 0, 0.14)),
503
- 0 1px 5px 0 var(--v-shadow-key-ambient-opacity, rgba(0, 0, 0, 0.12));
655
+ .ai-dialog {
656
+ display: flex;
657
+ flex-direction: column;
658
+ gap: 12px;
504
659
  }
505
660
 
506
- .ai-thinking-toggle {
507
- &__switch {
508
- flex: 0 0 auto;
509
- margin-left: auto;
661
+ .ai-context {
662
+ align-items: center;
663
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
664
+ display: flex;
665
+ font-size: .8125rem;
666
+ gap: 6px;
667
+ min-width: 0;
668
+
669
+ &__label {
670
+ white-space: nowrap;
671
+ }
510
672
 
511
- --v-switch-scale: 1;
512
- --v-switch-track-height: 14px;
513
- --v-switch-thumb-height: 20px;
514
- --v-switch-thumb-width: 20px;
673
+ &__excerpt {
674
+ font-style: italic;
675
+ min-width: 0;
676
+ overflow: hidden;
677
+ text-overflow: ellipsis;
678
+ white-space: nowrap;
515
679
  }
516
680
  }
517
681
 
518
- .ai-reasoning-effort {
519
- &__select {
520
- flex: 0 0 auto;
521
- margin-left: auto;
682
+ .ai-examples {
683
+ display: flex;
684
+ flex-wrap: wrap;
685
+ gap: 6px;
686
+ }
687
+
688
+ .ai-prompt {
689
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
690
+ border-radius: var(--tf-dialog-field-radius, 8px);
691
+ padding: 6px 8px 6px 12px;
692
+ transition: border-color .15s;
522
693
 
523
- :deep(.v-input) {
524
- width: 140px;
525
- }
694
+ &:focus-within {
695
+ border-color: rgb(var(--v-theme-primary));
696
+ }
697
+
698
+ &--disabled {
699
+ opacity: .6;
700
+ }
701
+
702
+ &__input :deep(.v-field__input) {
703
+ padding-top: 4px;
704
+ padding-bottom: 4px;
705
+ }
706
+
707
+ &__bar {
708
+ align-items: center;
709
+ display: flex;
710
+ gap: 8px;
711
+ }
712
+
713
+ &__hint {
714
+ color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
715
+ font-size: .75rem;
716
+ white-space: nowrap;
717
+ }
718
+ }
719
+
720
+ .ai-thinking {
721
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
722
+ letter-spacing: normal;
723
+ margin-left: -8px;
724
+ text-transform: none;
725
+ }
726
+
727
+ .ai-status {
728
+ align-items: flex-start;
729
+ display: flex;
730
+ font-size: .8125rem;
731
+ gap: 6px;
732
+
733
+ &--warning {
734
+ color: rgb(var(--v-theme-warning));
735
+ }
736
+
737
+ &--error {
738
+ color: rgb(var(--v-theme-error));
739
+ }
740
+
741
+ .v-icon {
742
+ margin-top: 1px;
526
743
  }
527
744
  }
528
745
 
529
746
  .ai-reasoning {
530
- :deep(.v-expansion-panel-title) {
747
+ &__toggle {
748
+ align-items: center;
749
+ background: none;
750
+ border: 0;
531
751
  color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
752
+ cursor: pointer;
753
+ display: inline-flex;
754
+ font: inherit;
532
755
  font-size: .8125rem;
533
- font-weight: 500;
534
- padding: .625rem .75rem;
756
+ gap: 6px;
757
+ padding: 0;
535
758
  }
536
759
 
537
- :deep(.v-expansion-panel-title__icon) {
538
- color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
760
+ &__text {
761
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
762
+ font-family: 'JetBrainsMono', monospace;
763
+ font-size: .75rem;
764
+ line-height: 1.4;
765
+ margin: 6px 0 0 22px;
766
+ max-height: 200px;
767
+ overflow: auto;
768
+ white-space: pre-wrap;
769
+ word-break: break-word;
539
770
  }
540
771
  }
541
772
 
542
- .ai-reasoning__text {
543
- color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
544
- font-family: 'JetBrainsMono', monospace;
545
- font-size: .75rem;
546
- line-height: 1.4;
547
- margin: 0;
548
- max-height: 200px;
549
- overflow: auto;
550
- padding: .25rem .75rem .625rem;
551
- white-space: pre-wrap;
552
- word-break: break-word;
553
- }
773
+ .ai-result {
774
+ background: rgba(var(--v-theme-on-surface), .03);
775
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
776
+ border-radius: var(--tf-dialog-field-radius, 8px);
777
+ overflow: hidden;
778
+ position: relative;
779
+
780
+ &__progress {
781
+ left: 0;
782
+ position: absolute;
783
+ top: 0;
784
+ }
554
785
 
555
- .ai-char-count {
556
- font-size: .75rem;
557
- margin-top: .25rem;
558
- text-align: right;
786
+ &__placeholder {
787
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
788
+ font-size: .875rem;
789
+ padding: 12px;
790
+ }
791
+
792
+ // The preview keeps the whitespace of plain text, the way it is inserted
793
+ &__content {
794
+ font-size: .875rem;
795
+ line-height: 1.55;
796
+ max-height: 280px;
797
+ overflow: auto;
798
+ padding: 10px 12px;
799
+ white-space: pre-wrap;
800
+ word-break: break-word;
801
+
802
+ :deep(> :first-child) {
803
+ margin-top: 0;
804
+ }
805
+
806
+ :deep(> :last-child) {
807
+ margin-bottom: 0;
808
+ }
559
809
 
560
- &--exceeded {
561
- color: var(--red-soft);
810
+ :deep(p),
811
+ :deep(ul),
812
+ :deep(ol),
813
+ :deep(blockquote) {
814
+ margin: 0 0 .5em;
815
+ }
816
+
817
+ :deep(ul),
818
+ :deep(ol) {
819
+ padding-left: 1.5em;
820
+ }
821
+
822
+ :deep(img),
823
+ :deep(iframe),
824
+ :deep(video) {
825
+ max-width: 100%;
826
+ }
827
+ }
828
+
829
+ &__footer {
830
+ align-items: center;
831
+ border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
832
+ display: flex;
833
+ flex-wrap: wrap;
834
+ gap: 4px 12px;
835
+ padding: 2px 4px 2px 12px;
836
+
837
+ .v-btn {
838
+ letter-spacing: normal;
839
+ text-transform: none;
840
+ }
841
+ }
842
+
843
+ &__meta {
844
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
845
+ font-size: .75rem;
846
+ white-space: nowrap;
847
+
848
+ &--exceeded {
849
+ color: rgb(var(--v-theme-error));
850
+ }
562
851
  }
563
852
  }
564
853
 
565
- .ai-limit-alert {
566
- color: var(--red-soft, #B24A40) !important;
854
+ .ai-diff {
855
+ &__insert {
856
+ background: rgba(var(--v-theme-success), .16);
857
+ }
858
+
859
+ &__delete {
860
+ background: rgba(var(--v-theme-error), .12);
861
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
862
+ text-decoration: line-through;
863
+ }
567
864
  }
568
865
  </style>