Browse Source

feat: gguf file upload status

Timothy J. Baek 1 year ago
parent
commit
232401a042
3 changed files with 253 additions and 108 deletions
  1. 29 18
      backend/apps/web/routers/utils.py
  2. 1 0
      backend/requirements.txt
  3. 223 90
      src/lib/components/chat/SettingsModal.svelte

+ 29 - 18
backend/apps/web/routers/utils.py

@@ -26,17 +26,20 @@ from urllib.parse import urlparse
 
 
 def parse_huggingface_url(hf_url):
-    # Parse the URL
-    parsed_url = urlparse(hf_url)
+    try:
+        # Parse the URL
+        parsed_url = urlparse(hf_url)
 
-    # Get the path and split it into components
-    path_components = parsed_url.path.split("/")
+        # Get the path and split it into components
+        path_components = parsed_url.path.split("/")
 
-    # Extract the desired output
-    user_repo = "/".join(path_components[1:3])
-    model_file = path_components[-1]
+        # Extract the desired output
+        user_repo = "/".join(path_components[1:3])
+        model_file = path_components[-1]
 
-    return [user_repo, model_file]
+        return model_file
+    except ValueError:
+        return None
 
 
 async def download_file_stream(url, file_path, chunk_size=1024 * 1024):
@@ -49,7 +52,7 @@ async def download_file_stream(url, file_path, chunk_size=1024 * 1024):
 
     headers = {"Range": f"bytes={current_size}-"} if current_size > 0 else {}
 
-    timeout = aiohttp.ClientTimeout(total=60)  # Set the timeout
+    timeout = aiohttp.ClientTimeout(total=600)  # Set the timeout
 
     async with aiohttp.ClientSession(timeout=timeout) as session:
         async with session.get(url, headers=headers) as response:
@@ -62,7 +65,7 @@ async def download_file_stream(url, file_path, chunk_size=1024 * 1024):
 
                     done = current_size == total_size
                     progress = round((current_size / total_size) * 100, 2)
-                    yield f'data: {{"progress": {progress}, "current": {current_size}, "total": {total_size}}}\n\n'
+                    yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
 
                 if done:
                     file.seek(0)
@@ -76,6 +79,7 @@ async def download_file_stream(url, file_path, chunk_size=1024 * 1024):
                         res = {
                             "done": done,
                             "blob": f"sha256:{hashed}",
+                            "name": file.name,
                         }
                         os.remove(file_path)
 
@@ -86,16 +90,20 @@ async def download_file_stream(url, file_path, chunk_size=1024 * 1024):
 
 @router.get("/download")
 async def download(
-    url: str = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf",
+    url: str,
 ):
-    user_repo, model_file = parse_huggingface_url(url)
+    # url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf"
+    model_file = parse_huggingface_url(url)
 
-    os.makedirs("./uploads", exist_ok=True)
-    file_path = os.path.join("./uploads", f"{model_file}")
+    if model_file:
+        os.makedirs("./uploads", exist_ok=True)
+        file_path = os.path.join("./uploads", f"{model_file}")
 
-    return StreamingResponse(
-        download_file_stream(url, file_path), media_type="text/event-stream"
-    )
+        return StreamingResponse(
+            download_file_stream(url, file_path), media_type="text/event-stream"
+        )
+    else:
+        return None
 
 
 @router.post("/upload")
@@ -118,10 +126,12 @@ async def upload(file: UploadFile = File(...)):
                     f.write(chunk)
                     total += len(chunk)
                     done = total_size == total
+                    progress = round((total / total_size) * 100, 2)
 
                     res = {
+                        "progress": progress,
                         "total": total_size,
-                        "uploaded": total,
+                        "completed": total,
                     }
 
                     yield f"data: {json.dumps(res)}\n\n"
@@ -138,6 +148,7 @@ async def upload(file: UploadFile = File(...)):
                         res = {
                             "done": done,
                             "blob": f"sha256:{hashed}",
+                            "name": file.filename,
                         }
                         os.remove(file_path)
 

+ 1 - 0
backend/requirements.txt

@@ -12,6 +12,7 @@ passlib[bcrypt]
 uuid
 
 requests
+aiohttp
 pymongo
 bcrypt
 

+ 223 - 90
src/lib/components/chat/SettingsModal.svelte

@@ -50,14 +50,20 @@
 	};
 
 	// Models
+	let modelTransferring = false;
+
 	let modelTag = '';
+	let digest = '';
+	let pullProgress = null;
+
+	let modelUploadMode = 'file';
 	let modelInputFile = '';
-	let modelInputFileBlob = '';
+	let modelFileUrl = '';
 	let modelFileContent = `TEMPLATE """{{ .System }}\nUSER: {{ .Prompt }}\nASSSISTANT: """\nPARAMETER num_ctx 4096\nPARAMETER stop "</s>"\nPARAMETER stop "USER:"\nPARAMETER stop "ASSSISTANT:"`;
+	let modelFileDigest = '';
+	let uploadProgress = null;
 
 	let deleteModelTag = '';
-	let digest = '';
-	let pullProgress = null;
 
 	// Addons
 	let titleAutoGenerate = true;
@@ -162,6 +168,7 @@
 	};
 
 	const pullModelHandler = async () => {
+		modelTransferring = true;
 		const res = await fetch(`${API_BASE_URL}/pull`, {
 			method: 'POST',
 			headers: {
@@ -227,6 +234,8 @@
 		}
 
 		modelTag = '';
+		modelTransferring = false;
+
 		models.set(await getModels());
 	};
 
@@ -266,26 +275,42 @@
 	};
 
 	const uploadModelHandler = async () => {
-		const file = modelInputFile[0];
-		const formData = new FormData();
-		formData.append('file', file);
-
+		modelTransferring = true;
 		let uploaded = false;
+		let fileResponse = null;
+		let name = '';
 
-		const res = await fetch(`${WEBUI_API_BASE_URL}/utils/upload`, {
-			method: 'POST',
-			headers: {
-				...($settings.authHeader && { Authorization: $settings.authHeader }),
-				...($user && { Authorization: `Bearer ${localStorage.token}` })
-			},
-			body: formData
-		}).catch((error) => {
-			console.log(error);
-			return null;
-		});
+		if (modelUploadMode === 'file') {
+			const file = modelInputFile[0];
+			const formData = new FormData();
+			formData.append('file', file);
+
+			fileResponse = await fetch(`${WEBUI_API_BASE_URL}/utils/upload`, {
+				method: 'POST',
+				headers: {
+					...($settings.authHeader && { Authorization: $settings.authHeader }),
+					...($user && { Authorization: `Bearer ${localStorage.token}` })
+				},
+				body: formData
+			}).catch((error) => {
+				console.log(error);
+				return null;
+			});
+		} else {
+			fileResponse = await fetch(`${WEBUI_API_BASE_URL}/utils/download?url=${modelFileUrl}`, {
+				method: 'GET',
+				headers: {
+					...($settings.authHeader && { Authorization: $settings.authHeader }),
+					...($user && { Authorization: `Bearer ${localStorage.token}` })
+				}
+			}).catch((error) => {
+				console.log(error);
+				return null;
+			});
+		}
 
-		if (res && res.ok) {
-			const reader = res.body
+		if (fileResponse && fileResponse.ok) {
+			const reader = fileResponse.body
 				.pipeThrough(new TextDecoderStream())
 				.pipeThrough(splitStream('\n'))
 				.getReader();
@@ -300,14 +325,18 @@
 					for (const line of lines) {
 						if (line !== '') {
 							let data = JSON.parse(line.replace(/^data: /, ''));
-							console.log(data);
+
+							if (data.progress) {
+								uploadProgress = data.progress;
+							}
 
 							if (data.error) {
 								throw data.error;
 							}
 
 							if (data.done) {
-								modelInputFileBlob = data.blob;
+								modelFileDigest = data.blob;
+								name = data.name;
 								uploaded = true;
 							}
 						}
@@ -327,8 +356,8 @@
 					...($user && { Authorization: `Bearer ${localStorage.token}` })
 				},
 				body: JSON.stringify({
-					name: `${file.name}:latest`,
-					modelfile: `FROM @${modelInputFileBlob}\n${modelFileContent}`
+					name: `${name}:latest`,
+					modelfile: `FROM @${modelFileDigest}\n${modelFileContent}`
 				})
 			}).catch((err) => {
 				console.log(err);
@@ -390,7 +419,9 @@
 			}
 		}
 
-		modelTag = '';
+		modelFileUrl = '';
+		modelInputFile = '';
+		modelTransferring = false;
 		models.set(await getModels());
 	};
 
@@ -977,24 +1008,53 @@
 										/>
 									</div>
 									<button
-										class="px-3 text-gray-100 bg-emerald-600 hover:bg-emerald-700 rounded transition"
+										class="px-3 text-gray-100 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded transition"
 										on:click={() => {
 											pullModelHandler();
 										}}
+										disabled={modelTransferring}
 									>
-										<svg
-											xmlns="http://www.w3.org/2000/svg"
-											viewBox="0 0 16 16"
-											fill="currentColor"
-											class="w-4 h-4"
-										>
-											<path
-												d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
-											/>
-											<path
-												d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
-											/>
-										</svg>
+										{#if modelTransferring}
+											<div class="self-center">
+												<svg
+													class=" w-4 h-4"
+													viewBox="0 0 24 24"
+													fill="currentColor"
+													xmlns="http://www.w3.org/2000/svg"
+													><style>
+														.spinner_ajPY {
+															transform-origin: center;
+															animation: spinner_AtaB 0.75s infinite linear;
+														}
+														@keyframes spinner_AtaB {
+															100% {
+																transform: rotate(360deg);
+															}
+														}
+													</style><path
+														d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
+														opacity=".25"
+													/><path
+														d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
+														class="spinner_ajPY"
+													/></svg
+												>
+											</div>
+										{:else}
+											<svg
+												xmlns="http://www.w3.org/2000/svg"
+												viewBox="0 0 16 16"
+												fill="currentColor"
+												class="w-4 h-4"
+											>
+												<path
+													d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
+												/>
+												<path
+													d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
+												/>
+											</svg>
+										{/if}
 									</button>
 								</div>
 
@@ -1025,67 +1085,140 @@
 							</div>
 							<hr class=" dark:border-gray-700" />
 
-							<div>
-								<div class=" mb-2.5 text-sm font-medium">Upload a GGUF model</div>
+							<form
+								on:submit|preventDefault={() => {
+									uploadModelHandler();
+								}}
+							>
+								<div class=" mb-2 flex w-full justify-between">
+									<div class="  text-sm font-medium">Upload a GGUF model</div>
+
+									<button
+										class="p-1 px-3 text-xs flex rounded transition"
+										on:click={() => {
+											if (modelUploadMode === 'file') {
+												modelUploadMode = 'url';
+											} else {
+												modelUploadMode = 'file';
+											}
+										}}
+										type="button"
+									>
+										{#if modelUploadMode === 'file'}
+											<span class="ml-2 self-center">File Mode</span>
+										{:else}
+											<span class="ml-2 self-center">URL Mode</span>
+										{/if}
+									</button>
+								</div>
+
 								<div class="flex w-full mb-1.5">
-									<div class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}">
-										<input
-											id="model-upload-input"
-											type="file"
-											bind:files={modelInputFile}
-											on:change={() => {
-												console.log(modelInputFile);
-											}}
-											hidden
-										/>
+									<div class="flex flex-col w-full">
+										{#if modelUploadMode === 'file'}
+											<div
+												class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}"
+											>
+												<input
+													id="model-upload-input"
+													type="file"
+													bind:files={modelInputFile}
+													on:change={() => {
+														console.log(modelInputFile);
+													}}
+													accept=".gguf"
+													required
+													hidden
+												/>
 
-										<button
-											type="button"
-											class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-800"
-											on:click={() => {
-												document.getElementById('model-upload-input').click();
-											}}
-										>
-											{#if modelInputFile && modelInputFile.length > 0}
-												{modelInputFile[0].name}
-											{:else}
-												Click here to select
-											{/if}
-										</button>
+												<button
+													type="button"
+													class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-800"
+													on:click={() => {
+														document.getElementById('model-upload-input').click();
+													}}
+												>
+													{#if modelInputFile && modelInputFile.length > 0}
+														{modelInputFile[0].name}
+													{:else}
+														Click here to select
+													{/if}
+												</button>
+											</div>
+										{:else}
+											<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}">
+												<input
+													class="w-full rounded text-left py-2 px-4 dark:text-gray-300 dark:bg-gray-800 outline-none {modelFileUrl !==
+													''
+														? 'mr-2'
+														: ''}"
+													type="url"
+													required
+													bind:value={modelFileUrl}
+													placeholder="Type HuggingFace Resolve (Download) URL"
+												/>
+											</div>
+										{/if}
 									</div>
 
-									{#if modelInputFile && modelInputFile.length > 0}
+									{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
 										<button
-											class="px-3 text-gray-100 bg-emerald-600 hover:bg-emerald-700 rounded transition"
-											on:click={() => {
-												uploadModelHandler();
-											}}
+											class="px-3 text-gray-100 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-700 disabled:cursor-not-allowed rounded transition"
+											type="submit"
+											disabled={modelTransferring}
 										>
-											<svg
-												xmlns="http://www.w3.org/2000/svg"
-												viewBox="0 0 16 16"
-												fill="currentColor"
-												class="w-4 h-4"
-											>
-												<path
-													d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
-												/>
-												<path
-													d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
-												/>
-											</svg>
+											{#if modelTransferring}
+												<div class="self-center">
+													<svg
+														class=" w-4 h-4"
+														viewBox="0 0 24 24"
+														fill="currentColor"
+														xmlns="http://www.w3.org/2000/svg"
+														><style>
+															.spinner_ajPY {
+																transform-origin: center;
+																animation: spinner_AtaB 0.75s infinite linear;
+															}
+															@keyframes spinner_AtaB {
+																100% {
+																	transform: rotate(360deg);
+																}
+															}
+														</style><path
+															d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
+															opacity=".25"
+														/><path
+															d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
+															class="spinner_ajPY"
+														/></svg
+													>
+												</div>
+											{:else}
+												<svg
+													xmlns="http://www.w3.org/2000/svg"
+													viewBox="0 0 16 16"
+													fill="currentColor"
+													class="w-4 h-4"
+												>
+													<path
+														d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
+													/>
+													<path
+														d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
+													/>
+												</svg>
+											{/if}
 										</button>
 									{/if}
 								</div>
 
-								{#if modelInputFile && modelInputFile.length > 0}
+								{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
 									<div>
 										<div>
 											<div class=" my-2.5 text-sm font-medium">Modelfile Content</div>
 											<textarea
 												bind:value={modelFileContent}
 												class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none resize-none"
-												rows="6"
+												rows="8"
 											/>
 										</div>
 									</div>
@@ -1098,23 +1231,23 @@
 									>
 								</div>
 
-								{#if pullProgress !== null}
+								{#if uploadProgress !== null}
 									<div class="mt-2">
-										<div class=" mb-2 text-xs">Pull Progress</div>
+										<div class=" mb-2 text-xs">Upload Progress</div>
 										<div class="w-full rounded-full dark:bg-gray-800">
 											<div
 												class="dark:bg-gray-600 text-xs font-medium text-blue-100 text-center p-0.5 leading-none rounded-full"
-												style="width: {Math.max(15, pullProgress ?? 0)}%"
+												style="width: {Math.max(15, uploadProgress ?? 0)}%"
 											>
-												{pullProgress ?? 0}%
+												{uploadProgress ?? 0}%
 											</div>
 										</div>
 										<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
-											{digest}
+											{modelFileDigest}
 										</div>
 									</div>
 								{/if}
-							</div>
+							</form>
 							<hr class=" dark:border-gray-700" />
 
 							<div>