first commit

This commit is contained in:
Clelia (Astra) Bertelli
2026-02-10 16:16:47 +01:00
commit 5de72e0041
20 changed files with 2761 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
+1
View File
@@ -0,0 +1 @@
3.13
+41
View File
@@ -0,0 +1,41 @@
# Product Specs Comparison
A demo for LlamaIndex x PostHog observability.
## Set Up and Run
Clone the GitHub repository:
```bash
git clone https://github.com/run-llama/product-specs-comparison
cd product-specs-comparison
```
Install the project:
```bash
uv pip install .
```
Export the needed environment variables:
```bash
export OPENAI_API_KEY="..."
export LLAMA_CLOUD_API_KEY="..."
export POSTHOG_API_KEY="..."
export POSTHOG_HOST="..."
```
Or add the API keys to the [config file](./configs/config.json).
You can also modify LlamaParse settings within the configuration file.
Once the environment variables are set, you can start the web server with:
```bash
webserver-run
```
This will run the web server locally on port 8000. Optionally you can provide a `--port`, `--host` and `--log-level` value to configure the web server.
You can then upload two or three files containing tech products specs (such as the ones in [data](./data)) and compare them with a custom prompt.
+21
View File
@@ -0,0 +1,21 @@
{
"llm": {
"posthog_api_key": "$POSTHOG_API_KEY",
"posthog_host": "$POSTHOG_HOST",
"openai_api_key": "$OPENAI_API_KEY",
"model": "gpt-4.1"
},
"llama_cloud": {
"llama_cloud_api_key": "$LLAMA_CLOUD_API_KEY",
"llama_cloud_base_url": null
},
"llama_parse": {
"tier": "agentic",
"version": "latest",
"custom_prompt": "You are tasked with extracting product specifications from technical spec-sheets.",
"output_tables_as_markdown": true,
"high_res_ocr": true,
"outlined_table_extraction": true,
"expand": ["markdown"]
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
[build-system]
requires = ["uv_build>=0.9.10,<0.10.0"]
build-backend = "uv_build"
[project]
name = "product-specs-comparison"
version = "0.1.0"
description = "AI agent to produce meaningful product comparisons"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"aiofiles>=25.1.0",
"fastapi>=0.128.7",
"llama-cloud>=1.3.0",
"llama-index-llms-openai>=0.6.18",
"llama-index-workflows>=2.14.1",
"posthog>=7.8.5",
"python-multipart>=0.0.22",
"uvicorn>=0.40.0",
]
[project.scripts]
webserver-run = "product_specs_comparison.api:main"
+114
View File
@@ -0,0 +1,114 @@
import argparse
import asyncio
import base64
import mimetypes
import uuid
import aiofiles
import uvicorn
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader, select_autoescape
from .shared import ProductComparison, TransferFile
from .workflow import ComparisonEvent, InputEvent, ProductSpecsComparisonWorkflow
app = FastAPI()
async def _render_html_template(product_comparison: ProductComparison) -> str:
env = Environment(
loader=FileSystemLoader("./"), autoescape=select_autoescape(), enable_async=True
)
if product_comparison.best_for_user is not None:
template = await asyncio.to_thread(
env.get_template, "./static/comparison_with_rec.html"
)
return await template.render_async(
general_comparison=product_comparison.general_comparison,
best_for_user=product_comparison.best_for_user,
)
template = await asyncio.to_thread(env.get_template, "./static/comparison.html")
return await template.render_async(
general_comparison=product_comparison.general_comparison,
)
async def _render_error_template(error: str) -> str:
env = Environment(
loader=FileSystemLoader("./"), autoescape=select_autoescape(), enable_async=True
)
template = await asyncio.to_thread(env.get_template, "./static/error.html")
return await template.render_async(
error=error,
)
@app.post("/comparisons")
async def run_comparison_workflow(
pdf1: UploadFile = File(...),
pdf2: UploadFile = File(...),
pdf3: UploadFile | None = File(None),
prompt: str = Form(...),
) -> HTMLResponse:
files = [f for f in [pdf1, pdf2, pdf3] if f is not None]
transfer_files: list[TransferFile] = []
for file in files:
content = await file.read()
if file.content_type is None:
file_extension = ".pdf"
else:
file_extension = mimetypes.guess_extension(file.content_type) or ".pdf"
if file.filename is None:
file_name = "product-spec" + str(uuid.uuid4())[:8] + file_extension
else:
file_name = file.filename
transfer_files.append(
TransferFile(
file_content=base64.b64encode(content).decode("utf-8"),
file_name=file_name,
file_type=file.content_type or "application/pdf",
)
)
input_event = InputEvent(files=transfer_files, prompt=prompt)
wf = ProductSpecsComparisonWorkflow(timeout=600)
result = await wf.run(start_event=input_event)
assert isinstance(result, ComparisonEvent), f"Result is of type: {type(result)}"
if result.error is not None:
html_content = await _render_error_template(result.error)
else:
product_comparison = ProductComparison(
general_comparison=result.general_comparison or "no general comparison",
best_for_user=result.best_for_user,
)
html_content = await _render_html_template(product_comparison)
return HTMLResponse(content=html_content, media_type="text/html")
@app.get("/")
async def home() -> HTMLResponse:
async with aiofiles.open("./static/index.html") as f:
content = await f.read()
return HTMLResponse(content=content, media_type="text/html")
def main() -> None:
parser = argparse.ArgumentParser(description="Run the product comparison server")
parser.add_argument(
"--host", type=str, default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
)
parser.add_argument(
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
)
parser.add_argument(
"--log-level",
type=str,
default="info",
help="Log level for the web server",
choices=["critical", "error", "warning", "info", "debug", "trace"],
)
args = parser.parse_args()
uvicorn.run(app, host=args.host, port=args.port)
@@ -0,0 +1,3 @@
DEFAULT_OPENAI_MODEL = "gpt-5.1"
DEFAULT_LLAMA_CLOUD_BASE_URL = "https://api.cloud.llamaindex.ai"
FAILURE_STATUSES = ("FAILED", "CANCELLED")
+118
View File
@@ -0,0 +1,118 @@
import os
from typing import Annotated, Literal, cast
from llama_cloud import AsyncLlamaCloud
from llama_index.core.llms.structured_llm import StructuredLLM
from llama_index.llms.openai import OpenAI as LlamaOpenAI
from posthog import Posthog
from posthog.ai.openai import OpenAI
from pydantic import BaseModel
from workflows.resource import ResourceConfig
from .constants import DEFAULT_LLAMA_CLOUD_BASE_URL, DEFAULT_OPENAI_MODEL
from .shared import ProductComparison
class LlmConfig(BaseModel):
posthog_api_key: str
posthog_host: str
openai_api_key: str
model: str | None
def load(self) -> None:
if self.posthog_api_key.startswith("$"):
posthog_api_key = os.getenv(self.posthog_api_key.lstrip("$"))
if posthog_api_key is None:
raise ValueError(f"{self.posthog_api_key} not found in environment")
self.posthog_api_key = posthog_api_key
if self.openai_api_key.startswith("$"):
openai_api_key = os.getenv(self.openai_api_key.lstrip("$"))
if openai_api_key is None:
raise ValueError(f"{self.openai_api_key} not found in environment")
self.openai_api_key = openai_api_key
if self.posthog_host.startswith("$"):
posthog_host = os.getenv(self.posthog_host.lstrip("$"))
if posthog_host is None:
raise ValueError(f"{self.posthog_host} not found in environment")
self.posthog_host = posthog_host
if self.model is None:
self.model = DEFAULT_OPENAI_MODEL
class LlamaCloudConfig(BaseModel):
llama_cloud_api_key: str
llama_cloud_base_url: str | None
def load(self) -> None:
if self.llama_cloud_api_key.startswith("$"):
llama_cloud_api_key = os.getenv(self.llama_cloud_api_key.lstrip("$"))
if llama_cloud_api_key is None:
raise ValueError(f"{self.llama_cloud_api_key} not found in environment")
self.llama_cloud_api_key = llama_cloud_api_key
if self.llama_cloud_base_url is None:
self.llama_cloud_base_url = DEFAULT_LLAMA_CLOUD_BASE_URL
elif self.llama_cloud_base_url.startswith("$"):
llama_cloud_base_url = os.getenv(self.llama_cloud_base_url.lstrip("$"))
if llama_cloud_base_url is None:
raise ValueError(
f"{self.llama_cloud_base_url} not found in environment"
)
self.llama_cloud_base_url = llama_cloud_base_url
class LlamaParseConfig(BaseModel):
tier: Literal["fast", "cost_effective", "agentic", "agentic_plus"]
version: Literal["2026-01-08", "2025-12-31", "2025-12-18", "2025-12-11", "latest"]
expand: list[str]
output_tables_as_markdown: bool
high_res_ocr: bool
outlined_table_extraction: bool
custom_prompt: str
def add_markdown_if_not_in_expand(self) -> None:
if "markdown" not in self.expand:
self.expand.append("markdown")
def get_llm(
config: Annotated[
LlmConfig,
ResourceConfig(
config_file="configs/config.json",
path_selector="llm",
label="LLM Config",
description="Config for the LLM resource",
),
],
) -> StructuredLLM:
config.load()
posthog = Posthog(
config.posthog_api_key,
host=config.posthog_host,
)
openai_client = OpenAI(api_key=config.openai_api_key, posthog_client=posthog)
llm = LlamaOpenAI(
model=cast(str, config.model),
api_key=config.openai_api_key,
)
llm._client = openai_client
return llm.as_structured_llm(ProductComparison)
def get_llama_cloud_client(
config: Annotated[
LlamaCloudConfig,
ResourceConfig(
config_file="configs/config.json",
path_selector="llama_cloud",
label="LlamaCloud Config",
description="Configuration for LlamaCloud",
),
],
) -> AsyncLlamaCloud:
config.load()
return AsyncLlamaCloud(
api_key=config.llama_cloud_api_key, base_url=config.llama_cloud_base_url
)
+25
View File
@@ -0,0 +1,25 @@
import base64
from pydantic import BaseModel, Field
class TransferFile(BaseModel):
file_content: str
file_name: str
file_type: str
def _load_content(self) -> bytes:
return base64.b64decode(self.file_content, validate=True)
def as_llamacloud_file(self) -> tuple[str, bytes, str]:
return (self.file_name, self._load_content(), self.file_type)
class ProductComparison(BaseModel):
general_comparison: str = Field(
description="General comparison of the products based on their content"
)
best_for_user: str | None = Field(
description="Which product is the best based on the user's request. Report only if the user asks to identify the best product",
default=None,
)
+204
View File
@@ -0,0 +1,204 @@
from typing import Annotated, cast
from llama_cloud import AsyncLlamaCloud
from llama_cloud.types.parsing_create_params import (
OutputOptions,
OutputOptionsMarkdown,
OutputOptionsMarkdownTables,
ProcessingOptions,
ProcessingOptionsAutoModeConfiguration,
ProcessingOptionsAutoModeConfigurationParsingConf,
)
from llama_cloud.types.parsing_get_response import MarkdownPageMarkdownResultPage
from llama_index.core.base.llms.types import ChatMessage
from llama_index.core.llms.structured_llm import StructuredLLM
from pydantic import BaseModel
from workflows import Context, Workflow, step
from workflows.events import Event, StartEvent, StopEvent
from workflows.resource import Resource, ResourceConfig
from .constants import FAILURE_STATUSES
from .resources import (
LlamaParseConfig,
ProductComparison,
get_llama_cloud_client,
get_llm,
)
from .shared import TransferFile
class WorkflowState(BaseModel):
prompt: str = ""
num_files: int = 0
file_id_to_file_name: dict[str, str] = {}
class InputEvent(StartEvent):
files: list[TransferFile]
prompt: str
class FileUploadedEvent(Event):
file_id: str
class FileParsedEvent(Event):
content: str | None = None
file_id: str
error: str | None = None
class ComparisonEvent(StopEvent):
general_comparison: str | None = None
best_for_user: str | None = None
error: str | None = None
class ProductSpecsComparisonWorkflow(Workflow):
@step
async def upload_to_llamacloud(
self,
ev: InputEvent,
ctx: Context[WorkflowState],
llama_cloud_client: Annotated[
AsyncLlamaCloud, Resource(get_llama_cloud_client)
],
) -> ComparisonEvent | None | FileUploadedEvent:
async with ctx.store.edit_state() as state:
state.num_files = len(ev.files)
state.prompt = ev.prompt
for file in ev.files:
try:
to_uplaod = file.as_llamacloud_file()
file_obj = await llama_cloud_client.files.create(
file=to_uplaod,
purpose="parse",
)
state.file_id_to_file_name[file_obj.id] = to_uplaod[0]
print(
f"Uploaded file {to_uplaod[0]} with ID: {file_obj.id}",
flush=True,
)
ctx.send_event(FileUploadedEvent(file_id=file_obj.id))
except Exception as e:
return ComparisonEvent(
error=f"An error occurred while uploading files: {e}"
)
@step(num_workers=4)
async def parse_file(
self,
ev: FileUploadedEvent,
ctx: Context[WorkflowState],
llama_cloud_client: Annotated[
AsyncLlamaCloud, Resource(get_llama_cloud_client)
],
llama_parse_config: Annotated[
LlamaParseConfig,
ResourceConfig(
config_file="configs/config.json",
path_selector="llama_parse",
label="LlamaParse Config",
description="Config for LlamaParse input/output control",
),
],
) -> FileParsedEvent:
llama_parse_config.add_markdown_if_not_in_expand()
try:
result = await llama_cloud_client.parsing.parse(
tier=llama_parse_config.tier,
version=llama_parse_config.version,
expand=llama_parse_config.expand,
file_id=ev.file_id,
output_options=OutputOptions(
markdown=OutputOptionsMarkdown(
tables=OutputOptionsMarkdownTables(
output_tables_as_markdown=llama_parse_config.output_tables_as_markdown
)
)
),
processing_options=ProcessingOptions(
auto_mode_configuration=[
ProcessingOptionsAutoModeConfiguration(
parsing_conf=ProcessingOptionsAutoModeConfigurationParsingConf(
high_res_ocr=llama_parse_config.high_res_ocr,
outlined_table_extraction=llama_parse_config.outlined_table_extraction,
custom_prompt=llama_parse_config.custom_prompt,
)
)
]
),
)
except Exception as e:
return FileParsedEvent(
error=f"An error occurred while parsing the file: {e}",
file_id=ev.file_id,
)
else:
if result.job.status in FAILURE_STATUSES:
return FileParsedEvent(
error=result.job.error_message
or f"Parsing job for file {ev.file_id} finished with status: {result.job.status}",
file_id=ev.file_id,
)
if result.markdown is not None:
print(
f"Parsed file with ID: {ev.file_id}",
flush=True,
)
markdown = "\n\n".join(
[
page.markdown
for page in result.markdown.pages
if isinstance(page, MarkdownPageMarkdownResultPage)
]
)
return FileParsedEvent(
content=markdown,
file_id=ev.file_id,
)
return FileParsedEvent(
error="No content extracted from the file", file_id=ev.file_id
)
@step
async def produce_comparison(
self,
ev: FileParsedEvent,
ctx: Context[WorkflowState],
llm: Annotated[StructuredLLM, Resource(get_llm)],
) -> ComparisonEvent | None:
state = await ctx.store.get_state()
events = ctx.collect_events(ev, [FileParsedEvent] * state.num_files)
if events is None:
return None
full_content = "# Products to compare\n\n"
events = cast(list[FileParsedEvent], events)
for event in events:
file_name = state.file_id_to_file_name[event.file_id]
if event.content is not None:
full_content += f"## {file_name}\n\n{event.content[:20_000]}\n\n"
else:
print(
f"Error in file {file_name}: {event.error or 'unidentified error'}. Skipping...",
flush=True,
)
if full_content == "# Products to compare\n\n":
return ComparisonEvent(error="No products to compare")
prompt = f"{full_content}\n\nBased on the products to compare, can you produce a comparison of their specs that satisfies this user's request? {state.prompt}"
try:
result = await llm.achat(
messages=[ChatMessage(content=prompt, role="user")]
)
except Exception as e:
return ComparisonEvent(
error=f"An error occurred while generating the comparison: {e}"
)
else:
if result.message.content is not None:
response = ProductComparison.model_validate_json(result.message.content)
return ComparisonEvent(
general_comparison=response.general_comparison,
best_for_user=response.best_for_user,
)
return ComparisonEvent(error="No comparison produced")
+13
View File
@@ -0,0 +1,13 @@
<div class="space-y-6">
<!-- General Comparison Section -->
<div
class="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-200 shadow-sm"
>
<h3 class="text-xl font-semibold text-slate-800 mb-3">
General Comparison
</h3>
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">
{{ general_comparison }}
</div>
</div>
</div>
+25
View File
@@ -0,0 +1,25 @@
<div class="space-y-6">
<!-- General Comparison Section -->
<div
class="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-200 shadow-sm"
>
<h3 class="text-xl font-semibold text-slate-800 mb-3">
General Comparison
</h3>
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">
{{ general_comparison }}
</div>
</div>
<!-- Best For User Section -->
<div
class="bg-gradient-to-br from-green-50 to-emerald-50 rounded-xl p-6 border border-green-200 shadow-sm"
>
<h3 class="text-xl font-semibold text-slate-800 mb-3">
Recommendation
</h3>
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">
{{ best_for_user }}
</div>
</div>
</div>
+13
View File
@@ -0,0 +1,13 @@
<div class="space-y-6">
<!-- General Comparison Section -->
<div
class="bg-gradient-to-br from-red-50 to-red-150 rounded-xl p-6 border border-red-200 shadow-sm"
>
<h3 class="text-xl font-semibold text-slate-800 mb-3">
An error occurred
</h3>
<div class="text-slate-700 leading-relaxed whitespace-pre-wrap">
{{ error }}
</div>
</div>
</div>
+488
View File
@@ -0,0 +1,488 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Compare technical specifications across multiple product datasheets"
/>
<meta
name="keywords"
content="product comparison, specs, datasheet, pdf, analysis"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>
Product Specs Comparison - Compare Tech Product Specifications
</title>
<script src="//unpkg.com/alpinejs" defer></script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
<style type="text/tailwindcss">
@theme {
--font-inter: Inter, sans-serif;
--font-mono: "JetBrains Mono", monospace;
}
</style>
</head>
<body class="bg-gradient-to-br from-slate-50 to-blue-50 min-h-screen">
<header class="items-center text-center space-y-6 px-4">
<nav
class="max-w-5xl mx-auto grid grid-cols-2 md:grid-cols-2 gap-4 rounded-xl border border-slate-200 bg-white shadow-lg py-6 mt-8 mb-8"
>
<div>
<a
href="/"
class="font-inter text-2xl font-bold text-blue-600 hover:text-blue-700 transition-colors"
>Product Specs Comparison</a
>
</div>
<div>
<a
href="https://github.com/run-llama/product-specs-comparison"
class="font-inter text-lg text-slate-600 hover:text-blue-600 transition-colors"
>Star on GitHub</a
>
</div>
</nav>
<h1 class="font-inter text-4xl font-bold text-slate-800">
Product Specs Comparison
</h1>
<h2 class="font-inter text-xl text-slate-600 max-w-2xl mx-auto">
Upload product datasheets and get AI-powered comparisons of
technical specifications
</h2>
</header>
<div
x-data="{
files: [null, null, null],
fileNames: [null, null, null],
prompt: '',
setFile(index, file) {
if (file) {
this.files[index] = file;
this.fileNames[index] = file.name;
} else {
this.files[index] = null;
this.fileNames[index] = null;
}
},
clearFile(index) {
this.files[index] = null;
this.fileNames[index] = null;
const refName = 'fileInput' + index;
if (this.$refs[refName]) {
this.$refs[refName].value = '';
}
},
clearAll() {
for (let i = 0; i < 3; i++) {
this.clearFile(i);
}
this.prompt = '';
this.$refs.resultsDisplay.innerHTML = '';
},
getUploadedCount() {
return this.files.filter(f => f !== null).length;
}
}"
class="flex flex-col items-center justify-start p-6 w-full min-h-screen"
>
<div
class="max-w-5xl w-full bg-white rounded-xl shadow-lg p-8 space-y-8"
>
<!-- Instructions -->
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
<h3 class="font-inter font-semibold text-blue-900 mb-2">
How it works:
</h3>
<ol
class="list-decimal list-inside space-y-1 text-sm text-blue-800"
>
<li>Upload 2-3 product datasheets (PDF format)</li>
<li>
Enter your comparison question or leave blank for
general comparison
</li>
<li>
Click "Compare Products" to get AI-powered insights
</li>
</ol>
</div>
<form
hx-post="/comparisons"
hx-target="#comparisonResults"
hx-trigger="submit"
hx-encoding="multipart/form-data"
enctype="multipart/form-data"
class="w-full space-y-6"
>
<!-- File Upload Section -->
<div class="space-y-4">
<label
class="block font-inter font-semibold text-slate-700 text-lg"
>Upload Product Datasheets (PDF)</label
>
<!-- File Upload Slots -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Product 1 -->
<div
class="border-2 border-dashed rounded-lg p-4 transition-all"
:class="fileNames[0] ? 'border-green-400 bg-green-50' : 'border-slate-300 bg-slate-50'"
>
<input
type="file"
id="pdf1"
name="pdf1"
accept="application/pdf"
x-ref="fileInput0"
@change="setFile(0, $event.target.files[0])"
class="hidden"
/>
<div x-show="!fileNames[0]" class="text-center">
<button
type="button"
@click="$refs.fileInput0.click()"
class="w-full py-8 px-4 bg-white border-2 border-slate-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all flex flex-col items-center justify-center gap-2 group"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-slate-400 group-hover:text-blue-500"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line
x1="12"
y1="18"
x2="12"
y2="12"
/>
<line
x1="9"
y1="15"
x2="15"
y2="15"
/>
</svg>
<span
class="text-sm font-medium text-slate-600 group-hover:text-blue-600"
>Product 1</span
>
<span class="text-xs text-slate-500"
>Click to upload</span
>
</button>
</div>
<div x-show="fileNames[0]" class="space-y-3">
<div class="flex items-start gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-green-600 flex-shrink-0 mt-0.5"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
<span
class="text-sm text-slate-700 break-all flex-1"
x-text="fileNames[0]"
></span>
</div>
<button
@click="clearFile(0)"
type="button"
class="w-full px-3 py-1.5 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors"
>
Remove
</button>
</div>
</div>
<!-- Product 2 -->
<div
class="border-2 border-dashed rounded-lg p-4 transition-all"
:class="fileNames[1] ? 'border-green-400 bg-green-50' : 'border-slate-300 bg-slate-50'"
>
<input
type="file"
id="pdf2"
name="pdf2"
accept="application/pdf"
x-ref="fileInput1"
@change="setFile(1, $event.target.files[0])"
class="hidden"
/>
<div x-show="!fileNames[1]" class="text-center">
<button
type="button"
@click="$refs.fileInput1.click()"
class="w-full py-8 px-4 bg-white border-2 border-slate-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all flex flex-col items-center justify-center gap-2 group"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-slate-400 group-hover:text-blue-500"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line
x1="12"
y1="18"
x2="12"
y2="12"
/>
<line
x1="9"
y1="15"
x2="15"
y2="15"
/>
</svg>
<span
class="text-sm font-medium text-slate-600 group-hover:text-blue-600"
>Product 2</span
>
<span class="text-xs text-slate-500"
>Click to upload</span
>
</button>
</div>
<div x-show="fileNames[1]" class="space-y-3">
<div class="flex items-start gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-green-600 flex-shrink-0 mt-0.5"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
<span
class="text-sm text-slate-700 break-all flex-1"
x-text="fileNames[1]"
></span>
</div>
<button
@click="clearFile(1)"
type="button"
class="w-full px-3 py-1.5 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors"
>
Remove
</button>
</div>
</div>
<!-- Product 3 -->
<div
class="border-2 border-dashed rounded-lg p-4 transition-all"
:class="fileNames[2] ? 'border-green-400 bg-green-50' : 'border-slate-300 bg-slate-50'"
>
<input
type="file"
id="pdf3"
name="pdf3"
accept="application/pdf"
x-ref="fileInput2"
@change="setFile(2, $event.target.files[0])"
class="hidden"
/>
<div x-show="!fileNames[2]" class="text-center">
<button
type="button"
@click="$refs.fileInput2.click()"
class="w-full py-8 px-4 bg-white border-2 border-slate-300 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-all flex flex-col items-center justify-center gap-2 group"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-slate-400 group-hover:text-blue-500"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line
x1="12"
y1="18"
x2="12"
y2="12"
/>
<line
x1="9"
y1="15"
x2="15"
y2="15"
/>
</svg>
<span
class="text-sm font-medium text-slate-600 group-hover:text-blue-600"
>Product 3</span
>
<span class="text-xs text-slate-500"
>Click to upload</span
>
</button>
</div>
<div x-show="fileNames[2]" class="space-y-3">
<div class="flex items-start gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="text-green-600 flex-shrink-0 mt-0.5"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
<span
class="text-sm text-slate-700 break-all flex-1"
x-text="fileNames[2]"
></span>
</div>
<button
@click="clearFile(2)"
type="button"
class="w-full px-3 py-1.5 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition-colors"
>
Remove
</button>
</div>
</div>
</div>
<p class="text-sm text-slate-500 text-center">
<span x-text="getUploadedCount()"></span> of 3 files
uploaded
<template x-if="getUploadedCount() < 2">
<span class="text-orange-600 font-medium"
>(Minimum 2 required)</span
>
</template>
</p>
</div>
<!-- Prompt Input Section -->
<div class="space-y-3">
<label
for="prompt"
class="block font-inter font-semibold text-slate-700 text-lg"
>
Comparison Question
</label>
<textarea
id="prompt"
name="prompt"
x-model="prompt"
rows="4"
placeholder="e.g., Which product has better battery life? Compare processing power. What are the key differences in connectivity options?"
class="w-full px-4 py-3 border-2 border-slate-300 rounded-lg focus:border-blue-500 focus:ring-2 focus:ring-blue-200 outline-none transition-all resize-none font-inter"
></textarea>
</div>
<!-- Action Buttons -->
<div class="flex gap-4 justify-center pt-4">
<button
@click="clearAll()"
type="button"
class="px-6 py-3 bg-slate-500 text-white rounded-lg hover:bg-slate-600 transition-colors font-medium"
>
Clear All
</button>
<button
type="submit"
:disabled="getUploadedCount() < 2"
class="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:bg-slate-300 disabled:cursor-not-allowed shadow-lg hover:shadow-xl"
hx-indicator="#loading"
>
Compare Products
</button>
</div>
<!-- Loading Indicator -->
<div
id="loading"
class="htmx-indicator flex justify-center items-center gap-3 py-4"
>
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"
></div>
<span class="text-slate-600 font-medium"
>Analyzing documents...</span
>
</div>
</form>
<!-- Results Section -->
<div
id="comparisonResults"
class="mt-8"
x-ref="resultsDisplay"
></div>
</div>
<!-- Footer -->
<footer class="mt-12 text-center text-sm text-slate-500 pb-8">
<p>
Powered by AI • Upload PDF datasheets to compare technical
specifications
</p>
</footer>
</div>
</body>
</html>
Generated
+1662
View File
File diff suppressed because it is too large Load Diff