feat(api): add completion request cancellation and mermaid rendering

Add support for cancelling in-progress LLM completion requests via new /v1/completions/cancel endpoint with task tracking. Implement mermaid diagram rendering in the Milkdown editor with a new mermaidPlugin. Update copilotPlugin to properly abort requests with descriptive reasons. Refactor settings panel to handle system theme changes reactively. Add camera capture support for image uploads.
This commit is contained in:
2026-02-25 19:00:17 +08:00
parent e28125079c
commit 637456ee34
13 changed files with 2013 additions and 147 deletions
+113 -43
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="editor-container">
<div ref="root" class="milkdown-editor"></div>
@@ -46,7 +46,7 @@
</svg>
<span class="btn-tooltip">{{ t('importMd') }}</span>
</button>
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md" style="display:none">
<input type="file" ref="fileInputRef" @change="handleFileUpload" accept=".md,text/markdown,text/x-markdown" style="display:none">
<button
type="button"
@@ -79,11 +79,13 @@
<span class="btn-tooltip">{{ t('uploadImg') }}</span>
</button>
<div v-if="showImageDropdown" class="image-dropdown">
<button v-if="supportsCameraCapture" type="button" @click="triggerCameraCapture">{{ cameraUploadLabel }}</button>
<button type="button" @click="triggerImageUpload">{{ t('uploadImg') }}</button>
<button type="button" @click="showUrlDialog = true; showImageDropdown = false">{{ t('insertUrl') }}</button>
</div>
</div>
<input type="file" ref="imageInputRef" @change="handleImageUpload" accept="image/*" style="display:none">
<input type="file" ref="cameraInputRef" @change="handleImageUpload" accept="image/*" capture="environment" style="display:none">
<button
type="button"
@@ -137,6 +139,7 @@ import { editorViewCtx, serializerCtx } from '@milkdown/kit/core'
import { Selection } from '@milkdown/prose/state'
import { undo, redo, undoDepth, redoDepth } from '@milkdown/prose/history'
import { copilotPlugin, copilotConfigCtx, copilotGhostMark, setCopilotEnabled, interruptCopilot, COPILOT_PLUGIN_KEY, SIZE_LIMIT, checkSizeLimit, clearGhostSuggestion } from '../plugins/copilotPlugin'
import { mermaidRenderPreview, codeBlockConfig } from '../plugins/mermaidPlugin'
import { fetchSuggestion } from '../utils/api.js'
import { useSettingsStore } from '../stores/settings'
import { OCR_URL } from '../utils/config.js'
@@ -149,6 +152,7 @@ const t = (key) => settings.t[key]
const root = ref(null)
const fileInputRef = ref(null)
const imageInputRef = ref(null)
const cameraInputRef = ref(null)
const aiEnabled = ref(true)
const contentSize = ref(0)
const showImageDropdown = ref(false)
@@ -160,6 +164,12 @@ const isOverLimit = computed(() => contentSize.value > SIZE_LIMIT)
const sizeInKB = computed(() => Math.floor(contentSize.value / 1024))
const undoLabel = computed(() => t('undo') || 'Undo')
const redoLabel = computed(() => t('redo') || 'Redo')
const cameraUploadLabel = computed(() => t('cameraUpload') || 'Use Camera')
const supportsCameraCapture = computed(() => {
if (typeof navigator === 'undefined') return false
const ua = navigator.userAgent || ''
return /Android|iPhone|iPad|iPod|Mobile/i.test(ua)
})
const aiButtonLabel = computed(() => {
if (isOverLimit.value) return t('docTooLarge')
return aiEnabled.value ? t('disableAI') : t('enableAI')
@@ -170,6 +180,8 @@ let markdownSyncTimer = null
let rootResizeObserver = null
const objectUrls = new Set()
const IMAGE_NODE_TYPES = new Set(['image', 'image-block', 'imageBlock'])
const MARKDOWN_EXT_RE = /\.md$/i
const IMAGE_EXT_RE = /\.(png|jpe?g|gif|webp|bmp|svg|heic|heif|avif)$/i
const revokeObjectUrl = (url) => {
if (!objectUrls.has(url)) return
@@ -287,6 +299,29 @@ const handleRedo = () => {
runHistoryCommand(redo)
}
const isMarkdownFile = (file) => {
if (!file) return false
const name = (file.name || '').toLowerCase()
const type = (file.type || '').toLowerCase()
return MARKDOWN_EXT_RE.test(name) || type === 'text/markdown' || type === 'text/x-markdown'
}
const isImageFile = (file) => {
if (!file) return false
const name = (file.name || '').toLowerCase()
const type = (file.type || '').toLowerCase()
return type.startsWith('image/') || IMAGE_EXT_RE.test(name)
}
const warnUnsupportedUploadType = () => {
alert(t('uploadFileTypeWarning') || 'Only Markdown (.md) files and image files are supported.')
}
const warnImageTooLarge = () => {
const limitMB = Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)
alert(t('imgTooLarge') || `Image too large. Max ${limitMB}MB.`)
}
const performOCR = async (file, cacheKey, imageHash = '') => {
if (!aiEnabled.value) return
@@ -329,6 +364,34 @@ const performOCR = async (file, cacheKey, imageHash = '') => {
reader.readAsDataURL(file)
}
const prepareImageFile = async (file) => {
if (!isImageFile(file)) {
warnUnsupportedUploadType()
return null
}
if (file.size > IMAGE_SIZE_LIMIT) {
warnImageTooLarge()
return null
}
const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl)
const arrayBuffer = await file.arrayBuffer()
const imageBytes = new Uint8Array(arrayBuffer)
const imageHash = await calculateImageHash(imageBytes)
const existingOcr = getOcrByHash(imageHash)
if (!existingOcr) {
performOCR(file, objectUrl, imageHash)
} else {
setOcrCache(objectUrl, existingOcr)
setOcrCache(file.name, existingOcr)
}
return objectUrl
}
onMounted(async () => {
if (!root.value) throw new Error('root.value is null')
updateEditorTailSpace()
@@ -341,12 +404,11 @@ onMounted(async () => {
crepe = new Crepe({
root: root.value,
defaultValue: '# 欢迎来到LLM-IN-TEXT\n\n一个即时LLM系统\n\n在下面开始你的创作...',
defaultValue: '# 娆㈣繋鏉ュ埌LLM-IN-TEXT\n\n涓€涓嵆鏃禠LM绯荤粺\n\n鍦ㄤ笅闈㈠紑濮嬩綘鐨勫垱浣?..',
features: {
[Crepe.Feature.Latex]: true,
[Crepe.Feature.ImageBlock]: true,
[Crepe.Feature.Table]: true,
[Crepe.Feature.Diagram]: true,
[Crepe.Feature.ListCheck]: true,
},
featureConfigs: {
@@ -356,22 +418,8 @@ onMounted(async () => {
},
[Crepe.Feature.ImageBlock]: {
onUpload: async (file) => {
if (file.size > IMAGE_SIZE_LIMIT) {
alert(`图片大小不能超过 ${Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)}MB`)
return null
}
const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl)
const arrayBuffer = await file.arrayBuffer()
const imageBytes = new Uint8Array(arrayBuffer)
const imageHash = await calculateImageHash(imageBytes)
const existingOcr = getOcrByHash(imageHash)
if (!existingOcr) {
performOCR(file, objectUrl, imageHash)
} else {
setOcrCache(objectUrl, existingOcr)
setOcrCache(file.name, existingOcr)
}
const objectUrl = await prepareImageFile(file)
if (!objectUrl) return null
clearCurrentGhost()
return objectUrl
}
@@ -391,6 +439,13 @@ onMounted(async () => {
})
})
crepe.editor.config((ctx) => {
ctx.update(codeBlockConfig.key, (prev) => ({
...prev,
renderPreview: mermaidRenderPreview,
}))
})
// Watch for debounce changes
watch(() => settings.debounceMs, (newVal) => {
if (!crepe) return
@@ -406,6 +461,7 @@ onMounted(async () => {
crepe.editor.use(copilotConfigCtx)
crepe.editor.use(copilotGhostMark)
crepe.editor.use(copilotPlugin)
await crepe.create()
@@ -440,8 +496,12 @@ const exportMarkdown = async () => {
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const now = new Date()
const pad = (n) => String(n).padStart(2, '0')
const datePart = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
a.href = url
a.download = `document-${Date.now()}.md`
a.download = `save${datePart}${timePart}.md`
document.body.appendChild(a)
a.click()
a.remove()
@@ -453,8 +513,25 @@ const triggerUpload = () => {
}
const handleFileUpload = async (event) => {
const file = event.target.files?.[0]
const input = event.target
const file = input.files?.[0]
if (!file) return
if (isImageFile(file)) {
const objectUrl = await prepareImageFile(file)
if (objectUrl) {
clearCurrentGhost()
insertImageAtCursor(objectUrl)
}
input.value = ''
return
}
if (!isMarkdownFile(file)) {
warnUnsupportedUploadType()
input.value = ''
return
}
try {
const text = await file.text()
@@ -465,7 +542,7 @@ const handleFileUpload = async (event) => {
console.error('[Error] Upload failed:', e)
}
event.target.value = ''
input.value = ''
}
const toggleAI = async () => {
@@ -491,6 +568,11 @@ const triggerImageUpload = () => {
imageInputRef.value?.click()
}
const triggerCameraCapture = () => {
showImageDropdown.value = false
cameraInputRef.value?.click()
}
const insertImageAtCursor = (src) => {
if (!crepe || !src) return
@@ -512,33 +594,20 @@ const insertImageAtCursor = (src) => {
}
const handleImageUpload = async (event) => {
const file = event.target.files?.[0]
const input = event.target
const file = input.files?.[0]
if (!file) return
if (file.size > IMAGE_SIZE_LIMIT) {
alert(`图片大小不能超过 ${Math.floor(IMAGE_SIZE_LIMIT / 1024 / 1024)}MB`)
event.target.value = ''
const objectUrl = await prepareImageFile(file)
if (!objectUrl) {
input.value = ''
return
}
const objectUrl = URL.createObjectURL(file)
objectUrls.add(objectUrl)
const arrayBuffer = await file.arrayBuffer()
const imageBytes = new Uint8Array(arrayBuffer)
const imageHash = await calculateImageHash(imageBytes)
const existingOcr = getOcrByHash(imageHash)
if (!existingOcr) {
performOCR(file, objectUrl, imageHash)
} else {
setOcrCache(objectUrl, existingOcr)
setOcrCache(file.name, existingOcr)
}
clearCurrentGhost()
insertImageAtCursor(objectUrl)
event.target.value = ''
input.value = ''
}
const insertImageFromUrl = () => {
@@ -976,3 +1045,4 @@ onUnmounted(() => {
background-color: var(--ghost-code-bg);
}
</style>
+103 -28
View File
@@ -1,5 +1,5 @@
<script setup>
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
import { useSettingsStore } from '../stores/settings'
import { useTheme } from '../composables/useTheme'
@@ -7,6 +7,7 @@ const store = useSettingsStore()
const { setTheme } = useTheme()
const isOpen = ref(false)
let systemThemeMediaQuery = null
const togglePanel = () => {
isOpen.value = !isOpen.value
@@ -16,15 +17,101 @@ const closePanel = () => {
isOpen.value = false
}
// Theme Handling
watch(() => store.theme, (newVal) => {
if (newVal === 'system') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setTheme(isDark ? 'dark' : 'light')
} else {
setTheme(newVal)
const applyThemeByPreference = () => {
if (store.theme === 'system') {
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
setTheme(isDark ? 'dark' : 'light')
return
}
setTheme('light')
return
}
}, { immediate: true })
setTheme(store.theme)
}
watch(
() => store.theme,
() => {
applyThemeByPreference()
},
{ immediate: true }
)
const handleSystemThemeChange = (event) => {
if (store.theme !== 'system') return
setTheme(event.matches ? 'dark' : 'light')
}
onMounted(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
if (typeof systemThemeMediaQuery.addEventListener === 'function') {
systemThemeMediaQuery.addEventListener('change', handleSystemThemeChange)
} else if (typeof systemThemeMediaQuery.addListener === 'function') {
systemThemeMediaQuery.addListener(handleSystemThemeChange)
}
})
onUnmounted(() => {
if (!systemThemeMediaQuery) return
if (typeof systemThemeMediaQuery.removeEventListener === 'function') {
systemThemeMediaQuery.removeEventListener('change', handleSystemThemeChange)
} else if (typeof systemThemeMediaQuery.removeListener === 'function') {
systemThemeMediaQuery.removeListener(handleSystemThemeChange)
}
})
const appearanceMode = computed({
get() {
if (store.backgroundType === 'warm') return 'warm'
if (store.backgroundType === 'reading') return 'reading'
if (store.backgroundType === 'image') return 'image'
if (store.theme === 'dark') return 'dark'
if (store.theme === 'light') return 'light'
return 'system'
},
set(mode) {
if (mode === 'dark') {
store.theme = 'dark'
store.backgroundType = 'default'
return
}
if (mode === 'light') {
store.theme = 'light'
store.backgroundType = 'default'
return
}
if (mode === 'system') {
store.theme = 'system'
store.backgroundType = 'default'
return
}
if (mode === 'warm') {
store.theme = 'light'
store.backgroundType = 'warm'
return
}
if (mode === 'reading') {
store.theme = 'light'
store.backgroundType = 'reading'
return
}
if (mode === 'image') {
store.theme = 'light'
store.backgroundType = 'image'
}
}
})
// Background Image Handling
const handleImageUpload = (event) => {
@@ -41,12 +128,6 @@ const handleImageUpload = (event) => {
// Helper to translate
const t = (key) => store.t[key]
// Background Style for App (This will be used in App.vue, but we preview it here or just logical check)
// UI Helpers
const tabs = ['General', 'Model', 'Appearance', 'About']
const currentTab = ref('General')
</script>
<template>
@@ -88,25 +169,18 @@ const currentTab = ref('General')
<h3>{{ t('appearance') }}</h3>
<div class="form-group">
<label>{{ t('theme') }}</label>
<div class="segment-control">
<button :class="{ active: store.theme === 'light' }" @click="store.theme = 'light'">{{ t('light') }}</button>
<button :class="{ active: store.theme === 'dark' }" @click="store.theme = 'dark'">{{ t('dark') }}</button>
<button :class="{ active: store.theme === 'system' }" @click="store.theme = 'system'">{{ t('system') }}</button>
</div>
</div>
<div class="form-group">
<label>{{ t('background') }}</label>
<select v-model="store.backgroundType" class="select-input">
<option value="default">{{ t('default') }}</option>
<label>{{ t('appearance') }}</label>
<select v-model="appearanceMode" class="select-input">
<option value="dark">{{ t('dark') }}</option>
<option value="light">{{ t('light') }}</option>
<option value="system">{{ t('system') }}</option>
<option value="warm">{{ t('warm') }}</option>
<option value="reading">{{ t('reading') }}</option>
<option value="image">{{ t('image') }}</option>
</select>
</div>
<div v-if="store.backgroundType === 'image'" class="form-group">
<div v-if="appearanceMode === 'image'" class="form-group">
<label>{{ t('image') }}</label>
<input type="file" accept="image/*" @change="handleImageUpload" class="file-input" />
@@ -488,3 +562,4 @@ const currentTab = ref('General')
opacity: 0.7;
}
</style>
+8 -8
View File
@@ -59,14 +59,14 @@ export const copilotGhostMark = $markSchema('copilot_ghost', () => ({
}
}))
function clearRuntimeRequests(runtime: CopilotRuntime, invalidateRequest = true) {
function clearRuntimeRequests(runtime: CopilotRuntime, invalidateRequest = true, abortReason = 'abort') {
if (runtime.debounceTimer) {
clearTimeout(runtime.debounceTimer)
runtime.debounceTimer = null
}
if (runtime.abortController) {
runtime.abortController.abort()
runtime.abortController.abort(abortReason)
runtime.abortController = null
}
@@ -302,7 +302,7 @@ function doFetchSuggestion(
const config = runtime.ctx.get(copilotConfigCtx.key)
if (runtime.abortController) {
runtime.abortController.abort()
runtime.abortController.abort('superseded')
runtime.abortController = null
}
@@ -611,7 +611,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
const nextHasGhost = Boolean(nextGhost?.suggestion && nextGhost.from < nextGhost.to)
if (docChanged && prevHasGhost && nextHasGhost) {
clearGhostText(nextView)
clearRuntimeRequests(runtime)
clearRuntimeRequests(runtime, true, 'superseded')
return
}
@@ -630,7 +630,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
const { from, to } = nextView.state.selection
if (from !== to) {
clearRuntimeRequests(runtime)
clearRuntimeRequests(runtime, true, 'manual')
return
}
@@ -638,7 +638,7 @@ export const copilotPlugin = $prose((ctx) => new Plugin<CopilotState>({
},
destroy: () => {
unbindDomListeners(activeDom)
clearRuntimeRequests(runtime)
clearRuntimeRequests(runtime, true, 'destroy')
runtimeByView.delete(view)
}
}
@@ -657,14 +657,14 @@ export function setCopilotEnabled(view: EditorView, value: boolean): void {
runtime.enabled = value
if (!value) {
clearRuntimeRequests(runtime)
clearRuntimeRequests(runtime, true, 'disabled')
}
}
export function interruptCopilot(view: EditorView): void {
const runtime = runtimeByView.get(view)
if (!runtime) return
clearRuntimeRequests(runtime)
clearRuntimeRequests(runtime, true, 'manual')
}
export function checkSizeLimit(view: EditorView): { size: number; overLimit: boolean } {
+67
View File
@@ -0,0 +1,67 @@
import { codeBlockConfig } from '@milkdown/kit/component/code-block'
import mermaid from 'mermaid'
// ── Mermaid init ────────────────────────────────────────────────────────────
let mermaidReady = false
let diagramCounter = 0
function ensureMermaid() {
if (mermaidReady) return
const dark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
mermaid.initialize({
startOnLoad: false,
theme: dark ? 'dark' : 'default',
securityLevel: 'loose',
fontFamily: 'inherit',
})
mermaidReady = true
}
// ── renderPreview ───────────────────────────────────────────────────────────
// Pass this function to codeBlockConfig.renderPreview via crepe.editor.config().
// For non-mermaid languages, return null to use the default preview renderer.
export async function mermaidRenderPreview(
language: string,
content: string,
applyPreview: (value: null | string | HTMLElement) => void,
): Promise<void> {
if (language !== 'mermaid') {
applyPreview(null)
return
}
ensureMermaid()
// Show a placeholder immediately
const wrapper = document.createElement('div')
wrapper.className = 'mermaid-block'
const inner = document.createElement('div')
inner.className = 'mermaid-inner'
inner.innerHTML = '<div class="mermaid-loading">···</div>'
wrapper.appendChild(inner)
applyPreview(wrapper)
const id = `mermaid-render-${++diagramCounter}`
const code = content.trim() || 'graph TD\nA-->B'
try {
const { svg } = await mermaid.render(id, code)
inner.innerHTML = svg
applyPreview(wrapper)
} catch (err) {
const pre = document.createElement('pre')
pre.className = 'mermaid-error'
pre.textContent = `Mermaid error:\n${err instanceof Error ? err.message : String(err)}`
inner.innerHTML = ''
inner.appendChild(pre)
applyPreview(wrapper)
}
}
// ── Milkdown plugin helper ─────────────────────────────────────────────────
// Call this inside a crepe.editor.config() callback:
// ctx.update(codeBlockConfig.key, (prev) => ({ ...prev, renderPreview: mermaidRenderPreview }))
//
// Re-export the config key so callers don't need to import @milkdown/components directly.
export { codeBlockConfig }
+63
View File
@@ -37,6 +37,8 @@
--toggle-moon: #475569;
--ghost-text: #7d8796;
--ghost-code-bg: rgba(15, 23, 42, 0.06);
--mermaid-max-width: 800px;
--mermaid-max-height: 420px;
--crepe-color-background: #ffffff;
--crepe-color-on-background: #000000;
@@ -189,3 +191,64 @@ body {
transition: none !important;
}
}
/* ── Mermaid diagram blocks ─────────────────────────────────────────── */
.mermaid-block {
display: block;
margin: 1em 0;
padding: 16px;
background: var(--crepe-color-surface, #f7f7f7);
border: 1px solid var(--panel-border, #d7deea);
border-radius: 8px;
cursor: pointer;
transition: border-color 160ms ease, box-shadow 160ms ease;
user-select: none;
}
.mermaid-block:hover {
border-color: var(--focus-ring, #3b82f6);
}
.mermaid-block.mermaid-selected {
border-color: var(--focus-ring, #3b82f6);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus-ring, #3b82f6) 25%, transparent);
}
.mermaid-inner {
display: block;
max-width: min(100%, var(--mermaid-max-width));
max-height: var(--mermaid-max-height);
margin: 0 auto;
overflow: auto;
}
.mermaid-inner svg {
display: block;
}
.mermaid-loading {
padding: 24px;
text-align: center;
font-size: 1.4em;
color: var(--muted-text, #6b7280);
letter-spacing: 0.2em;
animation: mermaid-pulse 1.2s ease-in-out infinite;
}
@keyframes mermaid-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
.mermaid-error {
padding: 12px 16px;
margin: 0;
background: color-mix(in srgb, var(--danger-text, #dc2626) 8%, transparent);
border: 1px solid var(--danger-text, #dc2626);
border-radius: 6px;
color: var(--danger-text, #dc2626);
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', monospace;
white-space: pre-wrap;
word-break: break-word;
}
+69 -6
View File
@@ -1,7 +1,51 @@
import { API_URL } from './config.js'
import { useSettingsStore } from '../stores/settings'
const API_KEY = 'your-secret-key-here'
let cachedIP = null
function generateRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function getCancelUrl(apiUrl) {
const normalized = String(apiUrl || '').replace(/\/+$/, '')
if (!normalized) return '/v1/completions/cancel'
if (normalized.endsWith('/v1/completions')) {
return `${normalized}/cancel`
}
return `${normalized}/cancel`
}
function normalizeAbortReason(reason) {
if (typeof reason === 'string' && reason.trim()) {
return reason.trim().slice(0, 64)
}
return 'abort'
}
async function sendCancelRequest(cancelUrl, requestId, reason) {
try {
await fetch(cancelUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
},
body: JSON.stringify({
request_id: requestId,
reason,
}),
})
} catch (e) {
console.debug('[Copilot] cancel request failed', e)
}
}
async function getClientIP() {
if (cachedIP) return cachedIP
try {
@@ -16,15 +60,30 @@ async function getClientIP() {
}
}
import { useSettingsStore } from '../stores/settings'
export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL) {
const requestId = generateRequestId()
const cancelUrl = getCancelUrl(apiUrl)
const onAbort = () => {
const reason = normalizeAbortReason(signal?.reason)
void sendCancelRequest(cancelUrl, requestId, reason)
}
if (signal) {
if (signal.aborted) {
onAbort()
} else {
signal.addEventListener('abort', onAbort, { once: true })
}
}
try {
const settings = useSettingsStore()
const clientIP = await getClientIP()
const headers = {
'Content-Type': 'application/json',
'X-API-Key': 'your-secret-key-here'
'X-API-Key': API_KEY,
'X-Request-Id': requestId,
}
// Only send IP if privacy mode is OFF
@@ -41,15 +100,15 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
user_preferences: {
language: settings.language,
currency: settings.currency,
timezone: settings.detectedTimezone
}
timezone: settings.detectedTimezone,
},
}
const res = await fetch(apiUrl, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal
signal,
})
if (!res.ok) {
@@ -95,5 +154,9 @@ export async function fetchSuggestion(prefix, suffix, signal, apiUrl = API_URL)
} else {
throw e
}
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort)
}
}
}