Skip to content

Commit

Permalink
Feedback improvements: API throttling issues, support deleting multip…
Browse files Browse the repository at this point in the history
…le images, and more (#47)

* refactor: checkForWaitingJobs improvements

* refactor: pending jobs controller

* refactor: cleanup pending image store

* refactor: update and fix rendering issue with image thumbnail

* fix: handle issue with hitting API limit

* chore: add test for ImageParams class

* fix: broken build

* feat: ability to export and import loras

* chore: update manifest and add additional icon sizes

* feat: add pagination to lora favorites page

* chore: handle undefined values in lora search

* chore: filter favorites and recents

* fix: stretched out image issue on lora page

* chore: cleanup civitai hook

* chore: fix issue with image thumbnail

* fix: add use client to linker

* feat: add changelog page with pagination

* chore: update changelog

* fix: skip importing lora if already exists in db

* fix: issue where input validation warning didn't return specific errors

* fix: issue with showing incorrect lora

* fix: broken build

* chore: allow opening lora panel if max lora count reached

* chore: update gitignore

* fix: pending image completed count funkyness

* fix: issue with throttle function and generate image endpoint

* chore: update changelog

* feat: implement task queue for managing multiple API calls

* feat: offload check logic to webworker

* chore: update deps

* feat: initial work on multi-image selection

* feat: add support for custom notes fields and support for auto-downgrade

* chore: select all images toggle

* feat: multiple image downloads

* fix: issue with duplicate images on gallery page

* chore: cleanup console logs

* feat: download multiple images method

* feat: add ability to delete image batch from popup modal

* fix: build issues

* feat: create local html image viewer for downloaded files

* chore: update changelog

* feat: add accordion option to section component

* chore: add accordions to embeddings components

* fix: handle error with missing values

* chore: add bundle analyzer package

* fix: correctly cast number types

* chore: update changelog

* feat: allow canceling of partial job without losing results

* fix: issue with not possible job being stuck and add feedback

* fix: handle 404 errors from expired jobs

* chore: update changelog

* chore: update npm script
  • Loading branch information
daveschumaker authored Aug 30, 2024
1 parent e5f7bb8 commit 70f07b7
Show file tree
Hide file tree
Showing 92 changed files with 4,402 additions and 1,155 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

# production
/build
buildId.json
build.sh
*.tar.gz

Expand Down
43 changes: 43 additions & 0 deletions app/_api/artbot/debugSaveResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

/**
* Saves API response data for debugging purposes on a local machine.
*
* This function sends a POST request to a local debug endpoint ('/api/debug/save-response')
* with the provided API response data. It's designed to be attached to API requests
* to log data for debugging and troubleshooting.
*
* @param id - A unique identifier for the API response
* @param data - The API response data to be saved (can be of any type)
* @param route - The API route that was called
*
* @example
* // Usage in an API call:
* const apiData = await fetchSomeApiData();
* await debugSaveApiResponse('uniqueId123', apiData, '/api/some-endpoint');
*/
export const debugSaveApiResponse = async (
id: string,
data: any,
route: string
) => {
if (process.env.NEXT_PUBLIC_SAVE_DEBUG_LOGS !== 'true') return

try {
const response = await fetch('/api/debug/save-response', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id, data, route })
})

if (!response.ok) {
throw new Error('Failed to save API response')
}

console.log('API response saved successfully')
} catch (error) {
console.error('Error saving API response:', error)
}
}
108 changes: 73 additions & 35 deletions app/_api/horde/check.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AppConstants } from '@/app/_data-models/AppConstants'
import { clientHeader } from '@/app/_data-models/ClientHeader'
import { HordeJobResponse } from '@/app/_types/HordeTypes'
import { debugSaveApiResponse } from '../artbot/debugSaveResponse'
import { TaskQueue } from '@/app/_data-models/TaskQueue'

interface HordeErrorResponse {
message: string
Expand All @@ -15,45 +17,81 @@ export interface CheckErrorResponse extends HordeErrorResponse {
statusCode: number
}

export default async function checkImage(
const MAX_REQUESTS_PER_SECOND = 2
const STATUS_CHECK_INTERVAL = 1025 / MAX_REQUESTS_PER_SECOND

const queueSystems = new Map<
string,
TaskQueue<CheckSuccessResponse | CheckErrorResponse>
>()

const getQueueSystem = (
jobId: string
): Promise<CheckSuccessResponse | CheckErrorResponse> {
let statusCode
try {
const res = await fetch(
`${AppConstants.AI_HORDE_PROD_URL}/api/v2/generate/check/${jobId}`,
{
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
'Client-Agent': clientHeader()
}
}
): TaskQueue<CheckSuccessResponse | CheckErrorResponse> => {
if (!queueSystems.has(jobId)) {
queueSystems.set(
jobId,
new TaskQueue<CheckSuccessResponse | CheckErrorResponse>(
`CheckQueue-${jobId}`,
STATUS_CHECK_INTERVAL,
{ preventDuplicates: true }
)
)
}
return queueSystems.get(jobId)!
}

// Worker initialization
let worker: Worker | null = null

statusCode = res.status
const data: HordeJobResponse | HordeErrorResponse = await res.json()
function getWorker() {
if (!worker && typeof Worker !== 'undefined') {
worker = new Worker(new URL('./check_webworker.ts', import.meta.url))
}
return worker
}

if ('done' in data && 'is_possible' in data) {
return {
success: true,
...data
const performCheckUsingWorker = (
jobId: string
): Promise<CheckSuccessResponse | CheckErrorResponse> => {
return new Promise((resolve) => {
const url = `${AppConstants.AI_HORDE_PROD_URL}/api/v2/generate/check/${jobId}`
const headers = {
'Content-Type': 'application/json',
'Client-Agent': clientHeader()
}

const workerInstance = getWorker()
workerInstance?.postMessage({ jobId, url, headers })

workerInstance?.addEventListener('message', (event) => {
const { jobId: returnedJobId, result } = event.data
if (returnedJobId === jobId) {
resolve(result)
}
} else {
return {
success: false,
message: data.message,
statusCode
})
})
}

export default async function checkImage(
jobId: string
): Promise<CheckSuccessResponse | CheckErrorResponse> {
const queueSystem = getQueueSystem(jobId)

console.log(`Enqueueing check task for jobId: ${jobId}`)
return await queueSystem.enqueue(
async () => {
console.log(`Processing check task for jobId: ${jobId}`)
const result = await performCheckUsingWorker(jobId)
if (result.success) {
await debugSaveApiResponse(
jobId,
result,
`/api/v2/generate/check/${jobId}`
)
}
}
} catch (err) {
console.log(`Error: Unable to check status for jobId: ${jobId}`)
console.log(err)

return {
success: false,
statusCode: statusCode ?? 0,
message: 'unknown error'
}
}
return result
},
jobId // Use jobId as the unique taskId
)
}
37 changes: 37 additions & 0 deletions app/_api/horde/check_webworker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
self.onmessage = async (event) => {
const { jobId, url, headers } = event.data

try {
const res = await fetch(url, { headers, cache: 'no-store' })
const statusCode = res.status
const data = await res.json()

if ('done' in data && 'is_possible' in data) {
self.postMessage({
jobId,
result: {
success: true,
...data
}
})
} else {
self.postMessage({
jobId,
result: {
success: false,
message: data.message,
statusCode
}
})
}
} catch (error) {
self.postMessage({
jobId,
result: {
success: false,
statusCode: 0,
message: 'unknown error'
}
})
}
}
96 changes: 59 additions & 37 deletions app/_api/horde/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { AppConstants } from '@/app/_data-models/AppConstants'
import { AppSettings } from '@/app/_data-models/AppSettings'
import { clientHeader } from '@/app/_data-models/ClientHeader'
import { HordeApiParams } from '@/app/_data-models/ImageParamsForHordeApi'
import { debugSaveApiResponse } from '../artbot/debugSaveResponse'
import { TaskQueue } from '@/app/_data-models/TaskQueue'

export interface GenerateSuccessResponse {
success: boolean
Expand All @@ -26,52 +28,72 @@ interface HordeErrorResponse {
errors: Array<{ [key: string]: string }>
}

export default async function generateImage(
const imageGenerationQueue = new TaskQueue<
GenerateSuccessResponse | GenerateErrorResponse
>('ImageGeneration', 600, { preventDuplicates: false })

let taskCounter = 0

export default function generateImage(
imageParams: HordeApiParams
): Promise<GenerateSuccessResponse | GenerateErrorResponse> {
let statusCode
try {
const apikey =
AppSettings.apikey()?.trim() || AppConstants.AI_HORDE_ANON_KEY
const res = await fetch(
`${AppConstants.AI_HORDE_PROD_URL}/api/v2/generate/async`,
{
body: JSON.stringify(imageParams),
cache: 'no-store',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Client-Agent': clientHeader(),
apikey: apikey
const taskId = `generate_${taskCounter++}`

return imageGenerationQueue.enqueue(async () => {
let statusCode = 0 // Initialize statusCode with a default value
try {
console.log(`Processing image generation task: ${taskId}`)
const apikey =
AppSettings.apikey()?.trim() || AppConstants.AI_HORDE_ANON_KEY
const res = await fetch(
`${AppConstants.AI_HORDE_PROD_URL}/api/v2/generate/async`,
{
body: JSON.stringify(imageParams),
cache: 'no-store',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Client-Agent': clientHeader(),
apikey: apikey
}
}
}
)
)

statusCode = res.status
const data: HordeSuccessResponse | HordeErrorResponse = await res.json()
statusCode = res.status
const data: HordeSuccessResponse | HordeErrorResponse = await res.json()

if ('id' in data) {
return {
success: true,
...data
if ('id' in data) {
await debugSaveApiResponse(
data.id,
{ data, params: imageParams },
`/api/v2/generate/async`
)
console.log(`Image generation task completed: ${taskId}`)
return {
success: true,
...data
}
} else {
console.log(`Image generation task failed: ${taskId}`)
return {
success: false,
statusCode,
errors: data.errors || [],
message: data.message
}
}
} else {
} catch (err) {
console.log(
`Error: Unable to send generate image request for task: ${taskId}`
)
console.log(err)

return {
success: false,
statusCode,
errors: data.errors || [],
message: data.message
errors: [{ error: 'unknown error' }],
message: 'unknown error'
}
}
} catch (err) {
console.log(`Error: Unable to send generate image request.`)
console.log(err)

return {
success: false,
statusCode: statusCode ?? 0,
errors: [{ error: 'unknown error' }],
message: 'unknown error'
}
}
}, taskId)
}
3 changes: 3 additions & 0 deletions app/_api/horde/status.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { AppConstants } from '@/app/_data-models/AppConstants'
import { clientHeader } from '@/app/_data-models/ClientHeader'
import { HordeJobResponse } from '@/app/_types/HordeTypes'
import { debugSaveApiResponse } from '../artbot/debugSaveResponse'

interface HordeErrorResponse {
message: string
Expand Down Expand Up @@ -45,6 +46,8 @@ export default async function imageStatus(
const data: HordeJobResponse | HordeErrorResponse = await res.json()

if ('done' in data) {
await debugSaveApiResponse(jobId, data, `/api/v2/generate/check/${jobId}`)

return {
success: true,
...data
Expand Down
14 changes: 11 additions & 3 deletions app/_components/AdvancedOptions/AddEmbedding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,18 @@ export default function AddEmbedding() {
[input, setInput]
)

let title = `Embeddings`
if (input.tis.length > 0) {
title += ` (${input.tis.length})`
}

return (
<Section anchor="add-embedding">
<div className="row justify-between">
<h2 className="row font-bold text-white">Embeddings</h2>
<Section
accordion
anchor="add-embedding"
title={title}
>
<div className="row justify-end mb-2">
<div className="row gap-1">
<Button
onClick={() => {
Expand Down
6 changes: 5 additions & 1 deletion app/_components/AdvancedOptions/AdditionalOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ export default function AdditionalOptions() {
const { input, setInput } = useInput()

return (
<Section title="Additional options" anchor="additional-options">
<Section
accordion
anchor="additional-options"
title="Additional options"
>
<label className="row gap-2 text-white">
<Switch
checked={input.post_processing.includes('strip_background')}
Expand Down
2 changes: 1 addition & 1 deletion app/_components/AdvancedOptions/ClipSkip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default function ClipSkip() {
min={1}
max={12}
onChange={(num) => {
setInput({ clipskip: num as unknown as number })
setInput({ clipskip: Number(num) as unknown as number })
}}
onMinusClick={() => {
if (Number(input.clipskip) - 1 < 1) {
Expand Down
Loading

0 comments on commit 70f07b7

Please sign in to comment.