feat: AI context, Webhooks, Kanban drag-drop, and UI fixes
This commit is contained in:
@@ -55,19 +55,19 @@
|
||||
<Controls />
|
||||
<MiniMap pannable zoomable class="bg-slate-800 border-slate-700" />
|
||||
<template #node-text="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="text" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
<FlowNodeRenderer :id="id" :data="data" type="text" :selected="selectedNode?.id === id" @update="updateNodeData" @execute="executeNode" />
|
||||
</template>
|
||||
<template #node-file="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="file" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
<FlowNodeRenderer :id="id" :data="data" type="file" :selected="selectedNode?.id === id" @update="updateNodeData" @execute="executeNode" />
|
||||
</template>
|
||||
<template #node-task="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="task" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
<FlowNodeRenderer :id="id" :data="data" type="task" :selected="selectedNode?.id === id" @update="updateNodeData" @execute="executeNode" />
|
||||
</template>
|
||||
<template #node-ai="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="ai" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
<FlowNodeRenderer :id="id" :data="data" type="ai" :selected="selectedNode?.id === id" @update="updateNodeData" @execute="executeNode" />
|
||||
</template>
|
||||
<template #node-webhook="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="webhook" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
<FlowNodeRenderer :id="id" :data="data" type="webhook" :selected="selectedNode?.id === id" @update="updateNodeData" @execute="executeNode" />
|
||||
</template>
|
||||
<template #node-git="{ data, id }">
|
||||
<FlowNodeRenderer :id="id" :data="data" type="git" :selected="selectedNode?.id === id" @update="updateNodeData" />
|
||||
@@ -134,6 +134,21 @@
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дедлайн (Срок)</label>
|
||||
<input v-model="selectedNode.data.alarm" @blur="commitNodeChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="selectedNode.type === 'webhook'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Webhook URL</label>
|
||||
<input v-model="selectedNode.data.url" @blur="commitNodeChange" placeholder="https://api.example.com/..." class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="selectedNode.type === 'webhook'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Метод</label>
|
||||
<select v-model="selectedNode.data.method" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedNode.type === 'ai'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Промпт (Инструкция)</label>
|
||||
<textarea v-model="selectedNode.data.prompt" @blur="commitNodeChange" rows="3" placeholder="Что должен сделать ИИ? (например: Отредактируй этот текст...)" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
|
||||
</div>
|
||||
<div v-if="selectedNode.data.script_id !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Скрипт</label>
|
||||
<input v-model="selectedNode.data.script_id" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none font-mono" />
|
||||
@@ -168,6 +183,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Force rebuild to fix Vite 404 issue -->
|
||||
<script setup>
|
||||
import { VueFlow } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
@@ -429,34 +445,65 @@ const onNodeDoubleClick = (event) => {
|
||||
const node = event.node
|
||||
|
||||
// 1. Cross-Canvas Navigation Logic
|
||||
// Check if it's a file node or has a URL/file reference to another canvas
|
||||
const targetId = node.data?.file || node.data?.url || ''
|
||||
|
||||
if (targetId && (node.type === 'file' || targetId.includes('.canvas') || targetId.startsWith('/canvas/'))) {
|
||||
// Clean up ID (e.g. "project.canvas" -> "project", "/canvas/project" -> "project")
|
||||
const cleanId = targetId.replace('.canvas', '').replace('/canvas/', '')
|
||||
console.log('[Navigation] Opening cross-linked canvas:', cleanId)
|
||||
navigateTo(`/canvas/${cleanId}`)
|
||||
return // Stop further processing
|
||||
return
|
||||
} else if (node.type === 'file' && !targetId) {
|
||||
alert('У этого узла еще нет ссылки на другую карту. Выберите карту в панели свойств (справа).')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Task Toggle Logic (fallback)
|
||||
const currentState = node.data?.state || 'todo'
|
||||
|
||||
// Quick toggle workflow: todo -> in_progress -> review -> completed -> todo
|
||||
const nextStateMap = {
|
||||
'todo': 'in_progress',
|
||||
'in_progress': 'review',
|
||||
'review': 'completed',
|
||||
'completed': 'todo',
|
||||
'blocked': 'todo'
|
||||
if (node.type === 'task') {
|
||||
const currentState = node.data?.state || 'todo'
|
||||
const nextStateMap = {
|
||||
'todo': 'in_progress',
|
||||
'in_progress': 'review',
|
||||
'review': 'completed',
|
||||
'completed': 'todo',
|
||||
'blocked': 'todo'
|
||||
}
|
||||
const nextState = nextStateMap[currentState] || 'todo'
|
||||
updateNodeData({ id: node.id, data: { state: nextState } })
|
||||
}
|
||||
}
|
||||
|
||||
const executeNode = async ({ id, type }) => {
|
||||
console.log(`[FlowEditor] Executing node ${id} of type ${type}`)
|
||||
|
||||
const nextState = nextStateMap[currentState] || 'todo'
|
||||
updateNodeData({ id: node.id, data: { state: nextState } })
|
||||
// Collect context from incoming nodes
|
||||
const incomingEdges = elements.value.filter(el => el.target === id)
|
||||
const contextNodes = incomingEdges.map(edge => {
|
||||
const sourceNode = elements.value.find(el => el.id === edge.source)
|
||||
return {
|
||||
id: sourceNode?.id,
|
||||
type: sourceNode?.type,
|
||||
text: sourceNode?.data?.text || sourceNode?.data?.label || ''
|
||||
}
|
||||
}).filter(n => n.text)
|
||||
|
||||
try {
|
||||
const res = await $fetch(`/api/nodes/${id}/execute`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId: props.canvasId,
|
||||
context: contextNodes
|
||||
},
|
||||
headers: authHeaders.value
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
console.log(`[FlowEditor] Execution successful:`, res.result)
|
||||
// Перезагружаем данные, чтобы увидеть результат
|
||||
await loadData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[FlowEditor] Execution failed:`, err)
|
||||
alert('Ошибка при выполнении узла: ' + (err.message || 'Неизвестная ошибка'))
|
||||
}
|
||||
}
|
||||
|
||||
const onConnect = (connection) => {
|
||||
|
||||
@@ -21,6 +21,17 @@
|
||||
<div v-if="type === 'task' && data.priority" :class="priorityBadgeClass">
|
||||
{{ priorityLabel }}
|
||||
</div>
|
||||
<!-- Кнопка запуска для спец-узлов -->
|
||||
<button
|
||||
v-if="['ai', 'webhook'].includes(type)"
|
||||
@click.stop="handleExecute"
|
||||
:disabled="isExecuting"
|
||||
class="ml-auto p-1.5 hover:bg-slate-700 rounded-md transition-colors group relative"
|
||||
title="Запустить выполнение"
|
||||
>
|
||||
<span v-if="isExecuting" class="block animate-spin text-blue-400">🌀</span>
|
||||
<span v-else class="block group-hover:scale-110 transition-transform">▶️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content preview for measurement -->
|
||||
@@ -69,7 +80,19 @@ const props = defineProps({
|
||||
selected: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
const emit = defineEmits(['update', 'execute'])
|
||||
const isExecuting = ref(false)
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (isExecuting.value) return
|
||||
isExecuting.value = true
|
||||
try {
|
||||
emit('execute', { id: props.id, type: props.type })
|
||||
} finally {
|
||||
// Состояние загрузки сбрасывается внешним событием или по таймауту для безопасности
|
||||
setTimeout(() => { isExecuting.value = false }, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
// Root element ref
|
||||
const rootEl = ref(null)
|
||||
|
||||
+78
-29
@@ -29,35 +29,45 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex-1 overflow-y-auto space-y-4 custom-scrollbar">
|
||||
<div
|
||||
v-for="task in getTasksForColumn(col.id)"
|
||||
:key="task.id"
|
||||
class="bg-slate-900 border border-slate-600 rounded-lg p-4 shadow-sm hover:border-blue-500 transition-colors cursor-pointer group"
|
||||
@click="openTask(task)"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="text-xs font-medium px-2 py-0.5 rounded" :class="getPriorityClass(task)">
|
||||
{{ getPriorityLabel(task) }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 font-mono">{{ task.id.split('-')[1] || task.id }}</div>
|
||||
</div>
|
||||
|
||||
<h4 class="font-medium text-white mb-2 line-clamp-2">{{ getTaskTitle(task) }}</h4>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-xs text-slate-400 flex items-center gap-1">
|
||||
<span>🗺️</span>
|
||||
<span class="truncate max-w-[120px]">{{ task.canvasName }}</span>
|
||||
</div>
|
||||
<div v-if="task.alarm || task.data?.alarm" class="text-xs text-orange-400 flex items-center gap-1" :title="new Date(task.alarm || task.data?.alarm).toLocaleString('ru-RU')">
|
||||
<span>⏰</span>
|
||||
<span>{{ formatShortDate(task.alarm || task.data?.alarm) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex-1 overflow-y-auto space-y-4 custom-scrollbar min-h-[200px]">
|
||||
<ClientOnly>
|
||||
<draggable
|
||||
v-model="columnsData[col.id]"
|
||||
group="tasks"
|
||||
item-key="id"
|
||||
class="space-y-4 min-h-[200px]"
|
||||
@change="(evt) => onTaskMove(evt, col.id)"
|
||||
>
|
||||
<template #item="{ element: task }">
|
||||
<div
|
||||
class="bg-slate-900 border border-slate-600 rounded-lg p-4 shadow-sm hover:border-blue-500 transition-colors cursor-pointer group"
|
||||
@click="openTask(task)"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="text-xs font-medium px-2 py-0.5 rounded" :class="getPriorityClass(task)">
|
||||
{{ getPriorityLabel(task) }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 font-mono">{{ task.id.split('-')[1] || task.id }}</div>
|
||||
</div>
|
||||
|
||||
<h4 class="font-medium text-white mb-2 line-clamp-2">{{ getTaskTitle(task) }}</h4>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-xs text-slate-400 flex items-center gap-1">
|
||||
<span>🗺️</span>
|
||||
<span class="truncate max-w-[120px]">{{ task.canvasName }}</span>
|
||||
</div>
|
||||
<div v-if="task.alarm || task.data?.alarm" class="text-xs text-orange-400 flex items-center gap-1" :title="new Date(task.alarm || task.data?.alarm).toLocaleString('ru-RU')">
|
||||
<span>⏰</span>
|
||||
<span>{{ formatShortDate(task.alarm || task.data?.alarm) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</ClientOnly>
|
||||
|
||||
<div v-if="getTasksForColumn(col.id).length === 0" class="text-center py-6 border-2 border-dashed border-slate-700 rounded-lg">
|
||||
<div v-if="columnsData[col.id].length === 0" class="text-center py-6 border-2 border-dashed border-slate-700 rounded-lg pointer-events-none">
|
||||
<span class="text-slate-500 text-sm">Нет задач</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,11 +78,19 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, reactive } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
const auth = useAuth()
|
||||
const loading = ref(true)
|
||||
const tasks = ref([])
|
||||
const columnsData = reactive({
|
||||
todo: [],
|
||||
in_progress: [],
|
||||
review: [],
|
||||
blocked: [],
|
||||
completed: []
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ id: 'todo', title: 'К выполнению', icon: '📋', color: 'text-slate-400' },
|
||||
@@ -91,6 +109,14 @@ const fetchTasks = async () => {
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
tasks.value = data.tasks || []
|
||||
|
||||
// Split tasks into columns
|
||||
columns.forEach(col => {
|
||||
columnsData[col.id] = tasks.value.filter(t => {
|
||||
const taskState = t.state || t.data?.state || 'todo'
|
||||
return taskState === col.id
|
||||
})
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load tasks', e)
|
||||
@@ -99,6 +125,29 @@ const fetchTasks = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onTaskMove = async (evt, newStatus) => {
|
||||
if (evt.added) {
|
||||
const task = evt.added.element
|
||||
console.log(`Moving task ${task.id} to ${newStatus}`)
|
||||
|
||||
try {
|
||||
await $fetch('/api/tasks/update-status', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
taskId: task.id,
|
||||
canvasId: task.canvasId,
|
||||
newState: newStatus
|
||||
},
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to update task status', e)
|
||||
// Rollback on error? (In a production app, we should)
|
||||
alert('Не удалось обновить статус задачи на сервере')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getTasksForColumn = (state) => {
|
||||
return tasks.value.filter(t => {
|
||||
const taskState = t.state || t.data?.state || 'todo'
|
||||
|
||||
@@ -58,7 +58,11 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 400, message: 'Invalid action' })
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Gitea sync error:', error)
|
||||
throw createError({ statusCode: 500, message: error?.message || 'Gitea operation failed' })
|
||||
const details = error.data || error.message || error
|
||||
console.error('Gitea sync error details:', details)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: typeof details === 'string' ? details : JSON.stringify(details)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
import { generateAIResponse } from '~/server/utils/ai'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const nodeId = getRouterParam(event, 'id')
|
||||
const body = await readBody(event)
|
||||
const { canvasId, context } = body
|
||||
|
||||
if (!nodeId || !canvasId) {
|
||||
throw createError({ statusCode: 400, message: 'Node ID and Canvas ID are required' })
|
||||
}
|
||||
|
||||
// 1. Найти холст и убедиться в правах доступа
|
||||
const canvas = await prisma.canvas.findUnique({
|
||||
where: { id: canvasId }
|
||||
})
|
||||
|
||||
if (!canvas) {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
|
||||
// Проверка прав (владелец или админ)
|
||||
if (canvas.ownerId !== user.id && user.role !== 'admin') {
|
||||
throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
}
|
||||
|
||||
// 2. Найти конкретный узел в JSON холста
|
||||
const nodes = (canvas.nodes as any[]) || []
|
||||
const nodeIdx = nodes.findIndex(n => n.id === nodeId)
|
||||
|
||||
if (nodeIdx === -1) {
|
||||
throw createError({ statusCode: 404, message: 'Node not found in canvas' })
|
||||
}
|
||||
|
||||
const node = nodes[nodeIdx]
|
||||
|
||||
try {
|
||||
let result = ''
|
||||
|
||||
if (node.type === 'ai') {
|
||||
// Формируем промпт с учетом контекста от соседей
|
||||
let fullPrompt = node.prompt || node.text || 'Analyze the provided context.'
|
||||
|
||||
if (context && context.length > 0) {
|
||||
const contextText = context.map((c: any) => `[Node ${c.id}]: ${c.text}`).join('\n\n')
|
||||
fullPrompt = `CONTEXT INFORMATION:\n${contextText}\n\nUSER REQUEST/INSTRUCTION:\n${fullPrompt}`
|
||||
}
|
||||
|
||||
console.log(`[AI Execute] Generating response with context for node ${nodeId}...`)
|
||||
result = await generateAIResponse(fullPrompt)
|
||||
|
||||
// Сохраняем результат в текст узла
|
||||
nodes[nodeIdx].text = result
|
||||
|
||||
} else if (node.type === 'webhook') {
|
||||
const url = node.url
|
||||
const method = node.method || 'GET'
|
||||
|
||||
if (!url) throw new Error('Webhook URL is not configured')
|
||||
|
||||
console.log(`[Webhook Execute] Calling ${method} ${url}...`)
|
||||
|
||||
const response = await $fetch<any>(url, {
|
||||
method,
|
||||
...(method === 'POST' ? { body: { context, timestamp: new Date() } } : {})
|
||||
})
|
||||
|
||||
result = typeof response === 'string' ? response : JSON.stringify(response, null, 2)
|
||||
nodes[nodeIdx].text = `### Response from ${url}\n\n\`\`\`json\n${result}\n\`\`\``
|
||||
|
||||
} else {
|
||||
throw createError({ statusCode: 400, message: `Execution not supported for type: ${node.type}` })
|
||||
}
|
||||
|
||||
// 3. Сохраняем обновленный холст
|
||||
await prisma.canvas.update({
|
||||
where: { id: canvasId },
|
||||
data: { nodes }
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result,
|
||||
message: 'Node executed and canvas updated'
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Node execution error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error.message || 'Execution failed'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
const body = await readBody(event)
|
||||
const { taskId, canvasId, newState } = body
|
||||
|
||||
if (!taskId || !canvasId || !newState) {
|
||||
throw createError({ statusCode: 400, message: 'taskId, canvasId and newState are required' })
|
||||
}
|
||||
|
||||
// 1. Найти холст
|
||||
const canvas = await prisma.canvas.findUnique({
|
||||
where: { id: canvasId }
|
||||
})
|
||||
|
||||
if (!canvas) {
|
||||
throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
}
|
||||
|
||||
// 2. Найти и обновить узел в JSON
|
||||
const nodes = (canvas.nodes as any[]) || []
|
||||
const nodeIdx = nodes.findIndex(n => n.id === taskId)
|
||||
|
||||
if (nodeIdx === -1) {
|
||||
throw createError({ statusCode: 404, message: 'Task node not found in canvas' })
|
||||
}
|
||||
|
||||
// Обновляем состояние
|
||||
nodes[nodeIdx].state = newState
|
||||
|
||||
// Также обновляем дату изменения, если нужно
|
||||
nodes[nodeIdx].updatedAt = new Date().toISOString()
|
||||
|
||||
// 3. Сохранить изменения в БД
|
||||
await prisma.canvas.update({
|
||||
where: { id: canvasId },
|
||||
data: { nodes }
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Task ${taskId} moved to ${newState}`
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
export const generateAIResponse = async (prompt: string, systemPrompt = 'You are a helpful assistant.') => {
|
||||
const config = useRuntimeConfig()
|
||||
const apiKey = config.deepseekApiKey || process.env.DEEPSEEK_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('DEEPSEEK_API_KEY is not configured')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $fetch('https://api.deepseek.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: {
|
||||
model: 'deepseek-chat',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
stream: false
|
||||
}
|
||||
}) as any
|
||||
|
||||
return response.choices[0].message.content
|
||||
} catch (error: any) {
|
||||
console.error('DeepSeek AI error:', error.data || error.message)
|
||||
throw new Error(`AI generation failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user