feat: v1.4.0 — review code, web search grounding, responsive preview
New features: - Review Code button sends generated code back to AI for review - Web search grounding via SearXNG (toggle in toolbar, enriches prompts) - Responsive preview with device size selector (Full/Desktop/Tablet/Mobile) - /api/search proxy route Fixes: - Model selector text color now white on dark theme - Post-coding button text overflow (shorter labels + min-w-0) - Duplicate activateArtifact button suppressed when action row shows
This commit is contained in:
29
CHANGELOG.md
29
CHANGELOG.md
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-03-18 19:57 UTC
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Review Code Button** — Post-coding action to send generated code back to AI for review
|
||||||
|
- `reviewCode()` function sends code with review-focused prompt
|
||||||
|
- Emerald-green "Review" button alongside Preview and Modify
|
||||||
|
- 3-column post-coding action grid: Preview / Review / Modify
|
||||||
|
- **Web Search Grounding** — Enrich AI prompts with live web search results
|
||||||
|
- `lib/services/search-api.ts` — SearXNG public API wrapper with 4 instance fallback
|
||||||
|
- `/api/search` route — server-side search proxy endpoint
|
||||||
|
- Toggle button in toolbar to enable/disable (amber highlight when active)
|
||||||
|
- Shows "Searching the web..." status while fetching
|
||||||
|
- Appends top 5 results as `[WEB SEARCH CONTEXT]` to user prompt
|
||||||
|
- **Responsive Preview** — Device size selector in canvas panel
|
||||||
|
- Full / Desktop (1280px) / Tablet (768px) / Mobile (375px) buttons
|
||||||
|
- Centered device frame with border, shadow, and scroll on overflow
|
||||||
|
- Only visible in preview mode, below Live Render / Inspect Code tabs
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Model selector text color** — Option text now white on dark theme (`bg-[#0b1414] text-white`)
|
||||||
|
- **Button text overflow** — Shortened labels (Preview/Review/Modify), added `min-w-0` for proper truncation
|
||||||
|
- **Duplicate activateArtifact button** — Original button now hides when post-coding action row is shown
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- Files modified: 1 (AIAssist.tsx: +85/-11 lines)
|
||||||
|
- Files added: 2 (`lib/services/search-api.ts`, `app/api/search/route.ts`)
|
||||||
|
- New state: `deviceSize`, `webSearchEnabled`
|
||||||
|
- New function: `reviewCode()`
|
||||||
|
|
||||||
## [1.3.0] - 2026-03-18 18:51 UTC
|
## [1.3.0] - 2026-03-18 18:51 UTC
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ Transform vague ideas into production-ready prompts and PRDs. PromptArch is an A
|
|||||||
- Live code rendering with `[PREVIEW]` tags
|
- Live code rendering with `[PREVIEW]` tags
|
||||||
- HTML, React, Python, and more — rendered in-browser
|
- HTML, React, Python, and more — rendered in-browser
|
||||||
- Auto-detect renderable vs. code-only previews
|
- Auto-detect renderable vs. code-only previews
|
||||||
|
- Responsive preview with device size selector (Full / Desktop / Tablet / Mobile)
|
||||||
|
|
||||||
|
### Code Review & Web Search
|
||||||
|
- **Review Code** — Send generated code back to AI for bug/security/performance review
|
||||||
|
- **Web Search Grounding** — Toggle to enrich prompts with live web search results via SearXNG
|
||||||
|
|
||||||
### Enhanced Prompt Engine
|
### Enhanced Prompt Engine
|
||||||
- 9 strategies: clarify, add-context, add-constraints, structure, add-examples, set-tone, expand, simplify, chain-of-thought
|
- 9 strategies: clarify, add-context, add-constraints, structure, add-examples, set-tone, expand, simplify, chain-of-thought
|
||||||
@@ -136,6 +141,7 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
| Version | Date | Highlights |
|
| Version | Date | Highlights |
|
||||||
|---------|------|------------|
|
|---------|------|------------|
|
||||||
|
| [1.4.0](CHANGELOG.md#140---2026-03-18) | 2026-03-18 19:57 | Review Code button, web search grounding, responsive preview, model selector fix |
|
||||||
| [1.3.0](CHANGELOG.md#130---2026-03-18) | 2026-03-18 18:51 | Plan-first workflow, OpenRouter, post-coding UX, enhanced prompt engine |
|
| [1.3.0](CHANGELOG.md#130---2026-03-18) | 2026-03-18 18:51 | Plan-first workflow, OpenRouter, post-coding UX, enhanced prompt engine |
|
||||||
| [1.2.0](CHANGELOG.md#120---2026-01-19) | 2026-01-19 19:16 | SEO agent fixes, Z.AI API validation |
|
| [1.2.0](CHANGELOG.md#120---2026-01-19) | 2026-01-19 19:16 | SEO agent fixes, Z.AI API validation |
|
||||||
| [1.1.0](CHANGELOG.md#110---2025-12-29) | 2025-12-29 17:55 | GitHub push, XLSX/HTML export, OAuth management |
|
| [1.1.0](CHANGELOG.md#110---2025-12-29) | 2025-12-29 17:55 | GitHub push, XLSX/HTML export, OAuth management |
|
||||||
|
|||||||
27
app/api/search/route.ts
Normal file
27
app/api/search/route.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Next.js API route: Web search proxy.
|
||||||
|
* Calls SearXNG public instances and returns top results.
|
||||||
|
* Endpoint: GET /api/search?q=your+query
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { searchWeb } from "@/lib/services/search-api";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const query = request.nextUrl.searchParams.get("q");
|
||||||
|
|
||||||
|
if (!query || query.trim().length < 3) {
|
||||||
|
return NextResponse.json({ results: [], error: "Query too short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await searchWeb(query);
|
||||||
|
return NextResponse.json({ results });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search API error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ results: [], error: "Search failed" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef, memo } from "react";
|
|||||||
import {
|
import {
|
||||||
MessageSquare, Send, Code2, Palette, Search,
|
MessageSquare, Send, Code2, Palette, Search,
|
||||||
Trash2, Copy, Monitor, StopCircle, X, Zap, Ghost,
|
Trash2, Copy, Monitor, StopCircle, X, Zap, Ghost,
|
||||||
Wand2, LayoutPanelLeft, Play, Orbit, Plus, Key
|
Wand2, LayoutPanelLeft, Play, Orbit, Plus, Key, ShieldCheck
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -509,11 +509,14 @@ export default function AIAssist() {
|
|||||||
|
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||||
const [currentAgent, setCurrentAgent] = useState(activeTab?.currentAgent || "general");
|
const [currentAgent, setCurrentAgent] = useState(activeTab?.currentAgent || "general");
|
||||||
const [previewData, setPreviewData] = useState<PreviewData | null>(activeTab?.previewData || null);
|
const [previewData, setPreviewData] = useState<PreviewData | null>(activeTab?.previewData || null);
|
||||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
const [showCanvas, setShowCanvas] = useState(activeTab?.showCanvas === true);
|
const [showCanvas, setShowCanvas] = useState(activeTab?.showCanvas === true);
|
||||||
const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
|
const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
|
||||||
|
const [deviceSize, setDeviceSize] = useState<"full" | "desktop" | "tablet" | "mobile">("full");
|
||||||
|
const deviceWidths: Record<string, string> = { full: "100%", desktop: "1280px", tablet: "768px", mobile: "375px" };
|
||||||
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
||||||
|
|
||||||
// Agent suggestion state
|
// Agent suggestion state
|
||||||
@@ -668,9 +671,30 @@ export default function AIAssist() {
|
|||||||
return m;
|
return m;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Web search grounding: enrich prompt with search results if enabled
|
||||||
|
let enrichedInput = finalInput;
|
||||||
|
if (webSearchEnabled) {
|
||||||
|
setStatus("Searching the web...");
|
||||||
|
try {
|
||||||
|
const searchRes = await fetch("/api/search?q=" + encodeURIComponent(finalInput.split("\n")[0].substring(0, 200)));
|
||||||
|
if (searchRes.ok) {
|
||||||
|
const searchData = await searchRes.json();
|
||||||
|
if (searchData.results && searchData.results.length > 0) {
|
||||||
|
const searchContext = searchData.results.slice(0, 5).map((r: { title: string; url: string; snippet: string }, i: number) =>
|
||||||
|
`${i + 1}. **${r.title}** (${r.url}) - ${r.snippet}`
|
||||||
|
).join("\n");
|
||||||
|
enrichedInput = `[WEB SEARCH CONTEXT - Top 5 relevant results]\n${searchContext}\n\n---\nUsing the above search results as reference context, answer the user query. Cite sources when relevant.\n\nUser query: ${finalInput}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Web search failed:", e);
|
||||||
|
}
|
||||||
|
setStatus(null);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await modelAdapter.generateAIAssistStream(
|
const response = await modelAdapter.generateAIAssistStream(
|
||||||
{
|
{
|
||||||
messages: [...formattedHistory, { role: "user" as const, content: finalInput, timestamp: new Date() }],
|
messages: [...formattedHistory, { role: "user" as const, content: enrichedInput, timestamp: new Date() }],
|
||||||
currentAgent,
|
currentAgent,
|
||||||
onChunk: (chunk) => {
|
onChunk: (chunk) => {
|
||||||
accumulated += chunk;
|
accumulated += chunk;
|
||||||
@@ -761,6 +785,12 @@ export default function AIAssist() {
|
|||||||
handleSendMessage(undefined, "Approved. Please generate the code according to the plan.", true);
|
handleSendMessage(undefined, "Approved. Please generate the code according to the plan.", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const reviewCode = () => {
|
||||||
|
if (!previewData?.data) return;
|
||||||
|
setAssistStep("generating");
|
||||||
|
const reviewPrompt = "Please review this generated code for bugs, security issues, performance problems, and best practices. Provide specific improvements:" + "\n" + "\n" + "```" + (previewData.language || "code") + "\n" + previewData.data + "\n" + "```";
|
||||||
|
};
|
||||||
|
|
||||||
const stopGeneration = () => {
|
const stopGeneration = () => {
|
||||||
if (abortController) {
|
if (abortController) {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
@@ -868,10 +898,20 @@ export default function AIAssist() {
|
|||||||
<select
|
<select
|
||||||
value={selectedModels[selectedProvider]}
|
value={selectedModels[selectedProvider]}
|
||||||
onChange={(e) => setSelectedModel(selectedProvider, e.target.value)}
|
onChange={(e) => setSelectedModel(selectedProvider, e.target.value)}
|
||||||
className="text-[10px] font-bold h-8 px-2 rounded-lg border border-blue-100 dark:border-blue-900 bg-white/80 dark:bg-[#0b1414]/80 focus:ring-2 focus:ring-blue-400/30 transition-all outline-none min-w-[120px]"
|
className="text-[10px] font-bold h-8 px-2 rounded-lg border border-blue-100 dark:border-blue-900 bg-white/80 dark:bg-[#0b1414]/80 focus:ring-2 focus:ring-blue-400/30 transition-all outline-none min-w-[120px] text-slate-900 dark:text-white"
|
||||||
>
|
>
|
||||||
{availableModels.map(m => <option key={m} value={m}>{m}</option>)}
|
{availableModels.map(m => <option key={m} value={m} className="bg-[#0b1414] text-white">{m}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => setWebSearchEnabled(prev => !prev)}
|
||||||
|
className={`flex items-center gap-1 h-8 px-2 rounded-lg text-[10px] font-bold transition-all border ${
|
||||||
|
webSearchEnabled
|
||||||
|
? "bg-amber-500/20 border-amber-500/40 text-amber-300"
|
||||||
|
: "bg-transparent border-transparent text-slate-400/70 hover:text-slate-300 hover:bg-slate-500/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5" /> {webSearchEnabled ? "ON" : "Search"}
|
||||||
|
</button>
|
||||||
<div className="flex items-center gap-1 ml-1 border-l border-blue-100 dark:border-blue-900 pl-2">
|
<div className="flex items-center gap-1 ml-1 border-l border-blue-100 dark:border-blue-900 pl-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1143,14 +1183,14 @@ export default function AIAssist() {
|
|||||||
onClick={() => { setAiPlan(null); setAssistStep("idle"); setInput("Modify this plan: "); setTimeout(() => { const el = document.querySelector<HTMLInputElement>(`[data-ai-input]`); if (el) el.focus(); }, 100); }}
|
onClick={() => { setAiPlan(null); setAssistStep("idle"); setInput("Modify this plan: "); setTimeout(() => { const el = document.querySelector<HTMLInputElement>(`[data-ai-input]`); if (el) el.focus(); }, 100); }}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-slate-500/10 hover:bg-slate-500/20 border-slate-500/20 text-slate-300 font-black uppercase text-[10px] tracking-widest py-4 rounded-xl"
|
className="bg-slate-500/10 hover:bg-slate-500/20 border-slate-500/20 text-slate-300 font-black uppercase text-[9px] tracking-wider py-4 rounded-xl min-w-0"
|
||||||
>
|
>
|
||||||
<LayoutPanelLeft className="h-3.5 w-3.5 mr-1.5" /> {t.modifyPlan}
|
<LayoutPanelLeft className="h-3.5 w-3.5 mr-1.5" /> {t.modifyPlan}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={approveAndGenerate}
|
onClick={approveAndGenerate}
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
className="bg-blue-600 hover:bg-blue-500 text-white font-black uppercase text-[10px] tracking-widest py-4 rounded-xl shadow-lg shadow-blue-500/20"
|
className="bg-blue-600 hover:bg-blue-500 text-white font-black uppercase text-[9px] tracking-wider py-4 rounded-xl shadow-lg shadow-blue-500/20 min-w-0"
|
||||||
>
|
>
|
||||||
{isProcessing ? t.startingEngine : t.startCoding}
|
{isProcessing ? t.startingEngine : t.startCoding}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1167,7 +1207,7 @@ export default function AIAssist() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{msg.role === "assistant" && msg.preview && (
|
{msg.role === "assistant" && msg.preview && !(assistStep === "preview" && i === aiAssistHistory.length - 1 && !isProcessing) && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1185,19 +1225,27 @@ export default function AIAssist() {
|
|||||||
|
|
||||||
{/* Post-coding action buttons */}
|
{/* Post-coding action buttons */}
|
||||||
{msg.role === "assistant" && assistStep === "preview" && i === aiAssistHistory.length - 1 && !isProcessing && (
|
{msg.role === "assistant" && assistStep === "preview" && i === aiAssistHistory.length - 1 && !isProcessing && (
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2 animate-in zoom-in-95 duration-300">
|
<div className="mt-4 grid grid-cols-3 gap-2 animate-in zoom-in-95 duration-300">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => { setShowCanvas(true); setViewMode(isPreviewRenderable(previewData as PreviewData) ? "preview" : "code"); }}
|
onClick={() => { setShowCanvas(true); setViewMode(isPreviewRenderable(previewData as PreviewData) ? "preview" : "code"); }}
|
||||||
className="bg-blue-600 hover:bg-blue-500 text-white font-black uppercase text-[10px] tracking-widest py-4 rounded-xl shadow-lg shadow-blue-500/20"
|
className="bg-blue-600 hover:bg-blue-500 text-white font-black uppercase text-[10px] tracking-widest py-4 rounded-xl shadow-lg shadow-blue-500/20"
|
||||||
>
|
>
|
||||||
<Zap className="h-3.5 w-3.5 mr-1.5" /> {t.activateArtifact}
|
<Zap className="h-3.5 w-3.5 mr-1" /> <span className="truncate">Preview</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={reviewCode}
|
||||||
|
disabled={!previewData?.data}
|
||||||
|
variant="outline"
|
||||||
|
className="bg-emerald-500/10 hover:bg-emerald-500/20 border-emerald-500/20 text-emerald-300 font-black uppercase text-[9px] tracking-wider py-4 rounded-xl min-w-0"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 mr-1" /> <span className="truncate">Review</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => { setAssistStep("idle"); setInput("Modify this: "); setTimeout(() => { const el = document.querySelector<HTMLInputElement>(`[data-ai-input]`); if (el) el.focus(); }, 100); }}
|
onClick={() => { setAssistStep("idle"); setInput("Modify this: "); setTimeout(() => { const el = document.querySelector<HTMLInputElement>(`[data-ai-input]`); if (el) el.focus(); }, 100); }}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-slate-500/10 hover:bg-slate-500/20 border-slate-500/20 text-slate-300 font-black uppercase text-[10px] tracking-widest py-4 rounded-xl"
|
className="bg-slate-500/10 hover:bg-slate-500/20 border-slate-500/20 text-slate-300 font-black uppercase text-[10px] tracking-widest py-4 rounded-xl"
|
||||||
>
|
>
|
||||||
<LayoutPanelLeft className="h-3.5 w-3.5 mr-1.5" /> Request Modifications
|
<LayoutPanelLeft className="h-3.5 w-3.5 mr-1" /> <span className="truncate">Modify</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1302,6 +1350,24 @@ export default function AIAssist() {
|
|||||||
{t.inspectCode}
|
{t.inspectCode}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{viewMode === "preview" && (
|
||||||
|
<div className="flex items-center gap-1 mt-1.5">
|
||||||
|
{([["full", "Full"], ["desktop", "Desktop"], ["tablet", "Tablet"], ["mobile", "Mobile"]] as const).map(([size, label]) => (
|
||||||
|
<button
|
||||||
|
key={size}
|
||||||
|
onClick={() => setDeviceSize(size)}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1 text-[9px] font-black uppercase rounded-md transition-all border",
|
||||||
|
deviceSize === size
|
||||||
|
? "bg-blue-500 text-white border-blue-500 shadow-md"
|
||||||
|
: "text-blue-300/50 border-transparent hover:text-blue-200 hover:bg-blue-900/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1345,14 +1411,22 @@ export default function AIAssist() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden relative">
|
<div className="flex-1 overflow-auto relative bg-[#050505]">
|
||||||
{viewMode === "preview" && currentPreviewData ? (
|
{viewMode === "preview" && currentPreviewData ? (
|
||||||
<CanvasErrorBoundary>
|
<CanvasErrorBoundary>
|
||||||
|
<div className="mx-auto transition-all duration-300 h-full"
|
||||||
|
style={deviceSize !== "full"
|
||||||
|
? { width: deviceWidths[deviceSize], maxWidth: "100%",
|
||||||
|
border: "1px solid rgba(30,58,138,0.3)",
|
||||||
|
borderRadius: "12px", overflow: "hidden",
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.5)" }
|
||||||
|
: undefined}>
|
||||||
<LiveCanvas
|
<LiveCanvas
|
||||||
data={currentPreviewData.data || ""}
|
data={currentPreviewData.data || ""}
|
||||||
type={currentPreviewData.type || "preview"}
|
type={currentPreviewData.type || "preview"}
|
||||||
isStreaming={!!currentPreviewData.isStreaming}
|
isStreaming={!!currentPreviewData.isStreaming}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</CanvasErrorBoundary>
|
</CanvasErrorBoundary>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full bg-[#050505] p-8 font-mono text-sm overflow-auto scrollbar-thin scrollbar-thumb-blue-900">
|
<div className="h-full bg-[#050505] p-8 font-mono text-sm overflow-auto scrollbar-thin scrollbar-thumb-blue-900">
|
||||||
|
|||||||
66
lib/services/search-api.ts
Normal file
66
lib/services/search-api.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Web search API wrapper using SearXNG public instances.
|
||||||
|
* No API key required — free for server-side use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
snippet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARXNG_INSTANCES = [
|
||||||
|
"https://searx.be",
|
||||||
|
"https://search.sapti.me",
|
||||||
|
"https://searx.tiekoetter.com",
|
||||||
|
"https://search.bus-hit.me",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function searchSearXNG(query: string): Promise<SearchResult[]> {
|
||||||
|
for (const instance of SEARXNG_INSTANCES) {
|
||||||
|
try {
|
||||||
|
const url = `${instance}/search?q=${encodeURIComponent(query)}&format=json&categories=general&language=en`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "PromptArch/1.4 (https://rommark.dev)",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
|
if (!res.ok) continue;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const results: SearchResult[] = (data.results || [])
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((r: Record<string, string>) => ({
|
||||||
|
title: r.title || "",
|
||||||
|
url: r.url || "",
|
||||||
|
snippet: r.content || "",
|
||||||
|
}))
|
||||||
|
.filter((r: SearchResult) => r.title && r.url);
|
||||||
|
|
||||||
|
if (results.length > 0) return results;
|
||||||
|
} catch {
|
||||||
|
// Try next instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchWeb(query: string): Promise<SearchResult[]> {
|
||||||
|
// Clean the query — take first meaningful line, max 200 chars
|
||||||
|
const cleanQuery = query
|
||||||
|
.split("\n")[0]
|
||||||
|
.replace(/\[.*?\]/g, "")
|
||||||
|
.trim()
|
||||||
|
.substring(0, 200);
|
||||||
|
|
||||||
|
if (!cleanQuery || cleanQuery.length < 3) return [];
|
||||||
|
|
||||||
|
return searchSearXNG(cleanQuery);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "promptarch",
|
"name": "promptarch",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"description": "Transform vague ideas into production-ready prompts and PRDs",
|
"description": "Transform vague ideas into production-ready prompts and PRDs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user