Compare commits

...

4 Commits

19 changed files with 1310 additions and 312 deletions

View File

@@ -14,3 +14,7 @@ OLLAMA_ENDPOINT=https://ollama.com/api
ZAI_API_KEY=
ZAI_GENERAL_ENDPOINT=https://api.z.ai/api/paas/v4
ZAI_CODING_ENDPOINT=https://api.z.ai/api/coding/paas/v4
# Site Configuration (Required for OAuth in production)
# Set to your production URL (e.g., https://your-app.vercel.app)
NEXT_PUBLIC_SITE_URL=http://localhost:6002

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { normalizeOllamaBase, DEFAULT_OLLAMA_BASE } from "../constants";
const API_PREFIX = "/api";
function getApiKey(request: NextRequest): string | null {
return request.headers.get("x-ollama-api-key");
}
function getBaseUrl(request: NextRequest): string {
const header = request.headers.get("x-ollama-endpoint");
if (header && header.trim().length > 0) {
return normalizeOllamaBase(header);
}
return DEFAULT_OLLAMA_BASE;
}
export async function POST(request: NextRequest) {
const apiKey = getApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "Ollama API key is required" },
{ status: 401 }
);
}
const body = await request.json();
const baseUrl = getBaseUrl(request);
const targetUrl = `${baseUrl}${API_PREFIX}/chat`;
try {
const response = await fetch(targetUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
});
const payload = await response.text();
if (!response.ok) {
return NextResponse.json(
{ error: "Ollama chat request failed", details: payload },
{ status: response.status }
);
}
return NextResponse.json(payload ? JSON.parse(payload) : {});
} catch (error) {
console.error("Ollama chat proxy failed", error);
return NextResponse.json(
{ error: "Ollama chat request failed" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,7 @@
export const DEFAULT_OLLAMA_BASE = process.env.NEXT_PUBLIC_OLLAMA_ENDPOINT || process.env.OLLAMA_ENDPOINT || "https://ollama.com";
export function normalizeOllamaBase(url?: string): string {
if (!url) return DEFAULT_OLLAMA_BASE.replace(/\/$/, "");
const trimmed = url.trim();
if (!trimmed) return DEFAULT_OLLAMA_BASE.replace(/\/$/, "");
return trimmed.replace(/\/$/, "");
}

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from "next/server";
import { normalizeOllamaBase, DEFAULT_OLLAMA_BASE } from "../constants";
const API_PREFIX = "/api";
function getApiKey(request: NextRequest): string | null {
return request.headers.get("x-ollama-api-key");
}
function getBaseUrl(request: NextRequest): string {
const header = request.headers.get("x-ollama-endpoint");
if (header && header.trim().length > 0) {
return normalizeOllamaBase(header);
}
return DEFAULT_OLLAMA_BASE;
}
async function fetchModelNames(url: string, apiKey: string): Promise<string[]> {
const response = await fetch(`${url}`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
if (!response.ok) {
const errorText = await response.text().catch(() => "Failed to parse");
throw new Error(`${response.status} ${response.statusText} - ${errorText}`);
}
const json = await response.json().catch(() => null);
const candidates = Array.isArray(json?.models)
? json.models
: Array.isArray(json?.data)
? json.data
: Array.isArray(json)
? json
: [];
const names: string[] = [];
for (const entry of candidates) {
if (!entry) continue;
const name = entry.name || entry.model || entry.id;
if (typeof name === "string" && name.length > 0) {
names.push(name);
}
}
return names;
}
export async function GET(request: NextRequest) {
const apiKey = getApiKey(request);
if (!apiKey) {
return NextResponse.json(
{ error: "Ollama API key is required" },
{ status: 401 }
);
}
const baseUrl = getBaseUrl(request);
const primaryUrl = `${baseUrl}${API_PREFIX}/v1/models`;
const fallbackUrl = `${baseUrl}${API_PREFIX}/tags`;
try {
const primaryModels = await fetchModelNames(primaryUrl, apiKey);
if (primaryModels.length > 0) {
return NextResponse.json({ models: primaryModels });
}
} catch (error) {
console.warn("[Ollama] Primary model fetch failed:", error);
}
try {
const fallbackModels = await fetchModelNames(fallbackUrl, apiKey);
if (fallbackModels.length > 0) {
return NextResponse.json({ models: fallbackModels });
}
} catch (error) {
console.warn("[Ollama] Fallback model fetch failed:", error);
}
return NextResponse.json(
{ models: [] },
{ status: 502 }
);
}

View File

@@ -0,0 +1,6 @@
export const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai";
export const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
export const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
export const QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
export const QWEN_OAUTH_SCOPE = "openid profile email model.completion";
export const QWEN_OAUTH_DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import {
QWEN_OAUTH_CLIENT_ID,
QWEN_OAUTH_DEVICE_CODE_ENDPOINT,
QWEN_OAUTH_SCOPE,
} from "../../constants";
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const { code_challenge, code_challenge_method } = body || {};
if (!code_challenge || !code_challenge_method) {
return NextResponse.json(
{ error: "code_challenge and code_challenge_method are required" },
{ status: 400 }
);
}
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: QWEN_OAUTH_CLIENT_ID,
scope: QWEN_OAUTH_SCOPE,
code_challenge,
code_challenge_method,
}),
});
const payload = await response.text();
if (!response.ok) {
return NextResponse.json(
{ error: "Device authorization failed", details: payload },
{ status: response.status }
);
}
return NextResponse.json(JSON.parse(payload));
} catch (error) {
console.error("Qwen device authorization failed", error);
return NextResponse.json(
{ error: "Device authorization failed" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { QWEN_OAUTH_CLIENT_ID, QWEN_OAUTH_TOKEN_ENDPOINT } from "../../constants";
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const { refresh_token } = body || {};
if (!refresh_token) {
return NextResponse.json(
{ error: "refresh_token is required" },
{ status: 400 }
);
}
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token,
client_id: QWEN_OAUTH_CLIENT_ID,
}),
});
const payload = await response.text();
if (!response.ok) {
return NextResponse.json(
{ error: "Token refresh failed", details: payload },
{ status: response.status }
);
}
return NextResponse.json(JSON.parse(payload));
} catch (error) {
console.error("Qwen token refresh failed", error);
return NextResponse.json(
{ error: "Token refresh failed" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import {
QWEN_OAUTH_CLIENT_ID,
QWEN_OAUTH_DEVICE_GRANT_TYPE,
QWEN_OAUTH_TOKEN_ENDPOINT,
} from "../../constants";
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const { device_code, code_verifier } = body || {};
if (!device_code || !code_verifier) {
return NextResponse.json(
{ error: "device_code and code_verifier are required" },
{ status: 400 }
);
}
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: QWEN_OAUTH_DEVICE_GRANT_TYPE,
client_id: QWEN_OAUTH_CLIENT_ID,
device_code,
code_verifier,
}),
});
const payload = await response.text();
if (!response.ok) {
return NextResponse.json(
{ error: "Token poll failed", details: payload },
{ status: response.status }
);
}
return NextResponse.json(JSON.parse(payload));
} catch (error) {
console.error("Qwen token poll failed", error);
return NextResponse.json(
{ error: "Token poll failed" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { QWEN_OAUTH_BASE_URL } from "../constants";
export async function GET(request: NextRequest) {
try {
const authorization = request.headers.get("authorization");
if (!authorization || !authorization.startsWith("Bearer ")) {
return NextResponse.json(
{ error: "Authorization required" },
{ status: 401 }
);
}
const token = authorization.slice(7);
const userResponse = await fetch(`${QWEN_OAUTH_BASE_URL}/api/v1/user`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!userResponse.ok) {
const errorText = await userResponse.text();
return NextResponse.json(
{ error: "Failed to fetch user info", details: errorText },
{ status: userResponse.status }
);
}
const userData = await userResponse.json();
return NextResponse.json({ user: userData });
} catch (error) {
console.error("Qwen user info failed", error);
return NextResponse.json(
{ error: "Failed to fetch user info" },
{ status: 500 }
);
}
}

View File

@@ -1,66 +1,17 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import Sidebar from "@/components/Sidebar";
import type { View } from "@/components/Sidebar";
import PromptEnhancer from "@/components/PromptEnhancer";
import PRDGenerator from "@/components/PRDGenerator";
import ActionPlanGenerator from "@/components/ActionPlanGenerator";
import UXDesignerPrompt from "@/components/UXDesignerPrompt";
import HistoryPanel from "@/components/HistoryPanel";
import SettingsPanel from "@/components/SettingsPanel";
import useStore from "@/lib/store";
import modelAdapter from "@/lib/services/adapter-instance";
export default function Home() {
const [currentView, setCurrentView] = useState<View>("enhance");
const { setQwenTokens, setApiKey } = useStore();
useEffect(() => {
// Handle OAuth callback
if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get("code");
if (code) {
// In a real app, you would exchange the code for tokens here
// Since we don't have a backend or real client secret, we'll simulate it
console.log("OAuth code received:", code);
// Mock token exchange
const mockAccessToken = "mock_access_token_" + Math.random().toString(36).substr(2, 9);
const tokens = {
accessToken: mockAccessToken,
expiresAt: Date.now() + 3600 * 1000, // 1 hour
};
setQwenTokens(tokens);
modelAdapter.setQwenOAuthTokens(tokens.accessToken, undefined, 3600);
// Save to localStorage
localStorage.setItem("promptarch-qwen-tokens", JSON.stringify(tokens));
// Clear the code from URL
window.history.replaceState({}, document.title, window.location.pathname);
// Switch to settings to show success (optional)
setCurrentView("settings");
}
// Load tokens from localStorage on init
const savedTokens = localStorage.getItem("promptarch-qwen-tokens");
if (savedTokens) {
try {
const tokens = JSON.parse(savedTokens);
if (tokens.expiresAt > Date.now()) {
setQwenTokens(tokens);
modelAdapter.setQwenOAuthTokens(tokens.accessToken, tokens.refreshToken, (tokens.expiresAt - Date.now()) / 1000);
}
} catch (e) {
console.error("Failed to load Qwen tokens:", e);
}
}
}
}, []);
const renderContent = () => {
switch (currentView) {
@@ -70,6 +21,8 @@ export default function Home() {
return <PRDGenerator />;
case "action":
return <ActionPlanGenerator />;
case "uxdesigner":
return <UXDesignerPrompt />;
case "history":
return <HistoryPanel />;
case "settings":

View File

@@ -3,16 +3,15 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import useStore from "@/lib/store";
import modelAdapter from "@/lib/services/adapter-instance";
import { Save, Key, Server, Eye, EyeOff } from "lucide-react";
import { cn } from "@/lib/utils";
export default function SettingsPanel() {
const { apiKeys, setApiKey, selectedProvider, setSelectedProvider, qwenTokens, setQwenTokens } = useStore();
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
const [isAuthLoading, setIsAuthLoading] = useState(false);
const handleSave = () => {
if (typeof window !== "undefined") {
@@ -43,6 +42,10 @@ export default function SettingsPanel() {
console.error("Failed to load API keys:", e);
}
}
const storedTokens = modelAdapter.getQwenTokenInfo();
if (storedTokens) {
setQwenTokens(storedTokens);
}
}
};
@@ -62,6 +65,28 @@ export default function SettingsPanel() {
}
};
const handleQwenAuth = async () => {
if (qwenTokens) {
setQwenTokens(null);
modelAdapter.updateQwenTokens();
modelAdapter.updateQwenApiKey(apiKeys.qwen || "");
return;
}
setIsAuthLoading(true);
try {
const token = await modelAdapter.startQwenOAuth();
setQwenTokens(token);
} catch (error) {
console.error("Qwen OAuth failed", error);
window.alert(
error instanceof Error ? error.message : "Qwen authentication failed"
);
} finally {
setIsAuthLoading(false);
}
};
useEffect(() => {
handleLoad();
}, []);
@@ -122,17 +147,14 @@ export default function SettingsPanel() {
variant={qwenTokens ? "secondary" : "outline"}
size="sm"
className="h-8"
onClick={() => {
if (qwenTokens) {
setQwenTokens(undefined as any);
localStorage.removeItem("promptarch-qwen-tokens");
modelAdapter.updateQwenApiKey(apiKeys.qwen || "");
} else {
window.location.href = modelAdapter.getQwenAuthUrl();
}
}}
onClick={handleQwenAuth}
disabled={isAuthLoading}
>
{qwenTokens ? "Logout from Qwen" : "Login with Qwen (OAuth)"}
{isAuthLoading
? "Signing in..."
: qwenTokens
? "Logout from Qwen"
: "Login with Qwen (OAuth)"}
</Button>
</div>
{qwenTokens && (

View File

@@ -2,10 +2,10 @@
import { Button } from "@/components/ui/button";
import useStore from "@/lib/store";
import { Sparkles, FileText, ListTodo, Settings, History } from "lucide-react";
import { Sparkles, FileText, ListTodo, Palette, History, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
export type View = "enhance" | "prd" | "action" | "history" | "settings";
export type View = "enhance" | "prd" | "action" | "uxdesigner" | "history" | "settings";
interface SidebarProps {
currentView: View;
@@ -19,6 +19,7 @@ export default function Sidebar({ currentView, onViewChange }: SidebarProps) {
{ id: "enhance" as View, label: "Prompt Enhancer", icon: Sparkles },
{ id: "prd" as View, label: "PRD Generator", icon: FileText },
{ id: "action" as View, label: "Action Plan", icon: ListTodo },
{ id: "uxdesigner" as View, label: "UX Designer Prompt", icon: Palette },
{ id: "history" as View, label: "History", icon: History, count: history.length },
{ id: "settings" as View, label: "Settings", icon: Settings },
];
@@ -81,6 +82,7 @@ export default function Sidebar({ currentView, onViewChange }: SidebarProps) {
<li> Use different providers for best results</li>
<li> Copy enhanced prompts to your AI agent</li>
<li> PRDs generate better action plans</li>
<li> UX Designer Prompt for design tasks</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,242 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import useStore from "@/lib/store";
import modelAdapter from "@/lib/services/adapter-instance";
import { Palette, Copy, Loader2, CheckCircle2, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
export default function UXDesignerPrompt() {
const {
currentPrompt,
selectedProvider,
selectedModels,
availableModels,
apiKeys,
isProcessing,
error,
setSelectedProvider,
setCurrentPrompt,
setEnhancedPrompt,
setProcessing,
setError,
setAvailableModels,
setSelectedModel,
} = useStore();
const [copied, setCopied] = useState(false);
const [generatedPrompt, setGeneratedPrompt] = useState<string | null>(null);
const selectedModel = selectedModels[selectedProvider];
const models = availableModels[selectedProvider] || modelAdapter.getAvailableModels(selectedProvider);
useEffect(() => {
if (typeof window !== "undefined") {
loadAvailableModels();
const saved = localStorage.getItem("promptarch-api-keys");
if (saved) {
try {
const keys = JSON.parse(saved);
if (keys.ollama) modelAdapter.updateOllamaApiKey(keys.ollama);
if (keys.zai) modelAdapter.updateZaiApiKey(keys.zai);
} catch (e) {
console.error("Failed to load API keys:", e);
}
}
}
}, [selectedProvider]);
const loadAvailableModels = async () => {
const fallbackModels = modelAdapter.getAvailableModels(selectedProvider);
setAvailableModels(selectedProvider, fallbackModels);
try {
const result = await modelAdapter.listModels(selectedProvider);
if (result.success && result.data) {
setAvailableModels(selectedProvider, result.data[selectedProvider] || fallbackModels);
}
} catch (error) {
console.error("Failed to load models:", error);
}
};
const handleGenerate = async () => {
if (!currentPrompt.trim()) {
setError("Please enter an app description");
return;
}
const apiKey = apiKeys[selectedProvider];
if (!apiKey || !apiKey.trim()) {
setError(`Please configure your ${selectedProvider.toUpperCase()} API key in Settings`);
return;
}
setProcessing(true);
setError(null);
setGeneratedPrompt(null);
try {
const result = await modelAdapter.generateUXDesignerPrompt(currentPrompt, selectedProvider, selectedModel);
if (result.success && result.data) {
setGeneratedPrompt(result.data);
setEnhancedPrompt(result.data);
} else {
setError(result.error || "Failed to generate UX designer prompt");
}
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setProcessing(false);
}
};
const handleCopy = async () => {
if (generatedPrompt) {
await navigator.clipboard.writeText(generatedPrompt);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleClear = () => {
setCurrentPrompt("");
setGeneratedPrompt(null);
setEnhancedPrompt(null);
setError(null);
};
return (
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-2">
<Card className="h-fit">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
UX Designer Prompt
</CardTitle>
<CardDescription>
Describe your app idea and get the BEST EVER prompt for UX design
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">AI Provider</label>
<div className="flex flex-wrap gap-2">
{(["ollama", "zai"] as const).map((provider) => (
<Button
key={provider}
variant={selectedProvider === provider ? "default" : "outline"}
size="sm"
onClick={() => setSelectedProvider(provider)}
className={cn(
"capitalize",
selectedProvider === provider && "bg-primary text-primary-foreground"
)}
>
{provider === "ollama" ? "Ollama" : "Z.AI"}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Model</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(selectedProvider, e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">App Description</label>
<Textarea
placeholder="e.g., A fitness tracking app with workout plans, nutrition tracking, and social features for sharing progress with friends"
value={currentPrompt}
onChange={(e) => setCurrentPrompt(e.target.value)}
className="min-h-[200px] resize-y"
/>
<p className="text-xs text-muted-foreground">
Describe what kind of app you want, target users, key features, and any specific design preferences.
</p>
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
{!apiKeys[selectedProvider] && (
<div className="mt-2 flex items-center gap-2">
<Settings className="h-4 w-4" />
<span className="text-xs">Configure API key in Settings</span>
</div>
)}
</div>
)}
<div className="flex gap-2">
<Button onClick={handleGenerate} disabled={isProcessing || !currentPrompt.trim()} className="flex-1">
{isProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
<>
<Palette className="mr-2 h-4 w-4" />
Generate UX Prompt
</>
)}
</Button>
<Button variant="outline" onClick={handleClear} disabled={isProcessing}>
Clear
</Button>
</div>
</CardContent>
</Card>
<Card className={cn(!generatedPrompt && "opacity-50")}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-500" />
Best Ever UX Prompt
</span>
{generatedPrompt && (
<Button variant="ghost" size="icon" onClick={handleCopy}>
{copied ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
)}
</CardTitle>
<CardDescription>
Comprehensive UX design prompt ready for designers
</CardDescription>
</CardHeader>
<CardContent>
{generatedPrompt ? (
<div className="rounded-md border bg-muted/50 p-4">
<pre className="whitespace-pre-wrap text-sm">{generatedPrompt}</pre>
</div>
) : (
<div className="flex h-[400px] items-center justify-center text-center text-sm text-muted-foreground">
Your comprehensive UX designer prompt will appear here
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,13 +1,10 @@
import type { ModelProvider, APIResponse, ChatMessage } from "@/types";
import QwenOAuthService from "./qwen-oauth";
import OllamaCloudService from "./ollama-cloud";
import ZaiPlanService from "./zai-plan";
import qwenOAuthService, { QwenOAuthConfig, QwenOAuthToken } from "./qwen-oauth";
export interface ModelAdapterConfig {
qwen?: {
apiKey?: string;
endpoint?: string;
};
qwen?: QwenOAuthConfig;
ollama?: {
apiKey?: string;
endpoint?: string;
@@ -20,34 +17,35 @@ export interface ModelAdapterConfig {
}
export class ModelAdapter {
private qwenService: QwenOAuthService;
private ollamaService: OllamaCloudService;
private zaiService: ZaiPlanService;
private qwenService = qwenOAuthService;
private preferredProvider: ModelProvider;
constructor(config: ModelAdapterConfig = {}, preferredProvider: ModelProvider = "qwen") {
this.qwenService = new QwenOAuthService(config.qwen);
constructor(config: ModelAdapterConfig = {}, preferredProvider: ModelProvider = "ollama") {
this.ollamaService = new OllamaCloudService(config.ollama);
this.zaiService = new ZaiPlanService(config.zai);
this.preferredProvider = preferredProvider;
if (config.qwen) {
if (config.qwen.apiKey) {
this.qwenService.setApiKey(config.qwen.apiKey);
}
if (config.qwen.accessToken) {
this.qwenService.setOAuthTokens({
accessToken: config.qwen.accessToken,
refreshToken: config.qwen.refreshToken,
expiresAt: config.qwen.expiresAt,
resourceUrl: config.qwen.resourceUrl,
});
}
}
}
setPreferredProvider(provider: ModelProvider): void {
this.preferredProvider = provider;
}
updateQwenApiKey(apiKey: string): void {
this.qwenService = new QwenOAuthService({ apiKey });
}
setQwenOAuthTokens(accessToken: string, refreshToken?: string, expiresIn?: number): void {
this.qwenService.setOAuthTokens(accessToken, refreshToken, expiresIn);
}
getQwenAuthUrl(): string {
return this.qwenService.getAuthorizationUrl();
}
updateOllamaApiKey(apiKey: string): void {
this.ollamaService = new OllamaCloudService({ apiKey });
}
@@ -56,6 +54,33 @@ export class ModelAdapter {
this.zaiService = new ZaiPlanService({ apiKey });
}
updateQwenApiKey(apiKey: string): void {
this.qwenService.setApiKey(apiKey);
}
updateQwenTokens(tokens?: QwenOAuthToken): void {
this.qwenService.setOAuthTokens(tokens);
}
async startQwenOAuth(): Promise<QwenOAuthToken> {
return await this.qwenService.signIn();
}
getQwenTokenInfo(): QwenOAuthToken | null {
return this.qwenService.getTokenInfo();
}
private buildFallbackProviders(...providers: ModelProvider[]): ModelProvider[] {
const seen = new Set<ModelProvider>();
return providers.filter((provider) => {
if (seen.has(provider)) {
return false;
}
seen.add(provider);
return true;
});
}
private async callWithFallback<T>(
operation: (service: any) => Promise<APIResponse<T>>,
providers: ModelProvider[]
@@ -92,20 +117,29 @@ export class ModelAdapter {
}
async enhancePrompt(prompt: string, provider?: ModelProvider, model?: string): Promise<APIResponse<string>> {
const providers: ModelProvider[] = provider ? [provider] : [this.preferredProvider, "ollama", "zai"];
const fallback = this.buildFallbackProviders(this.preferredProvider, "qwen", "ollama", "zai");
const providers: ModelProvider[] = provider ? [provider] : fallback;
return this.callWithFallback((service) => service.enhancePrompt(prompt, model), providers);
}
async generatePRD(idea: string, provider?: ModelProvider, model?: string): Promise<APIResponse<string>> {
const providers: ModelProvider[] = provider ? [provider] : ["ollama", "zai", this.preferredProvider];
const fallback = this.buildFallbackProviders(this.preferredProvider, "qwen", "ollama", "zai");
const providers: ModelProvider[] = provider ? [provider] : fallback;
return this.callWithFallback((service) => service.generatePRD(idea, model), providers);
}
async generateActionPlan(prd: string, provider?: ModelProvider, model?: string): Promise<APIResponse<string>> {
const providers: ModelProvider[] = provider ? [provider] : ["zai", "ollama", this.preferredProvider];
const fallback = this.buildFallbackProviders(this.preferredProvider, "qwen", "ollama", "zai");
const providers: ModelProvider[] = provider ? [provider] : fallback;
return this.callWithFallback((service) => service.generateActionPlan(prd, model), providers);
}
async generateUXDesignerPrompt(appDescription: string, provider?: ModelProvider, model?: string): Promise<APIResponse<string>> {
const fallback = this.buildFallbackProviders(this.preferredProvider, "qwen", "ollama", "zai");
const providers: ModelProvider[] = provider ? [provider] : fallback;
return this.callWithFallback((service) => service.generateUXDesignerPrompt(appDescription, model), providers);
}
async chatCompletion(
messages: ChatMessage[],
model: string,
@@ -137,7 +171,7 @@ export class ModelAdapter {
async listModels(provider?: ModelProvider): Promise<APIResponse<Record<ModelProvider, string[]>>> {
const fallbackModels: Record<ModelProvider, string[]> = {
qwen: ["qwen-coder-plus", "qwen-coder-turbo", "qwen-coder-lite"],
qwen: this.qwenService.getAvailableModels(),
ollama: ["gpt-oss:120b", "llama3.1", "gemma3", "deepseek-r1", "qwen3"],
zai: ["glm-4.7", "glm-4.5", "glm-4.5-air", "glm-4-flash", "glm-4-flashx"],
};
@@ -163,16 +197,6 @@ export class ModelAdapter {
console.error("[ModelAdapter] Failed to load Z.AI models, using fallback:", error);
}
}
if (provider === "qwen" || !provider) {
try {
const qwenModels = await this.qwenService.listModels();
if (qwenModels.success && qwenModels.data && qwenModels.data.length > 0) {
models.qwen = qwenModels.data;
}
} catch (error) {
console.error("[ModelAdapter] Failed to load Qwen models, using fallback:", error);
}
}
return { success: true, data: models };
}

View File

@@ -5,11 +5,42 @@ export interface OllamaCloudConfig {
endpoint?: string;
}
export interface OllamaModel {
name: string;
size?: number;
digest?: string;
}
const LOCAL_MODELS_URL = "/api/ollama/models";
const LOCAL_CHAT_URL = "/api/ollama/chat";
const DEFAULT_MODELS = [
"gpt-oss:120b",
"llama3.1:latest",
"llama3.1:70b",
"llama3.1:8b",
"llama3.1:instruct",
"gemma3:12b",
"gemma3:27b",
"gemma3:4b",
"gemma3:7b",
"deepseek-r1:70b",
"deepseek-r1:32b",
"deepseek-r1:14b",
"deepseek-r1:8b",
"deepseek-r1:1.5b",
"qwen3:72b",
"qwen3:32b",
"qwen3:14b",
"qwen3:7b",
"qwen3:4b",
"mistral:7b",
"mistral:instruct",
"codellama:34b",
"codellama:13b",
"codellama:7b",
"codellama:instruct",
"phi3:14b",
"phi3:3.8b",
"phi3:mini",
"gemma2:27b",
"gemma2:9b",
"yi:34b",
"yi:9b",
];
export class OllamaCloudService {
private config: OllamaCloudConfig;
@@ -17,38 +48,46 @@ export class OllamaCloudService {
constructor(config: OllamaCloudConfig = {}) {
this.config = {
endpoint: config.endpoint || "https://ollama.com/api",
apiKey: config.apiKey || process.env.OLLAMA_API_KEY,
endpoint: config.endpoint,
};
}
private getHeaders(): Record<string, string> {
private ensureApiKey(): string {
if (this.config.apiKey) {
return this.config.apiKey;
}
throw new Error("API key is required. Please configure your Ollama API key in settings.");
}
private getHeaders(additional: Record<string, string> = {}) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...additional,
"x-ollama-api-key": this.ensureApiKey(),
};
if (this.config.apiKey) {
headers["Authorization"] = `Bearer ${this.config.apiKey}`;
if (this.config.endpoint) {
headers["x-ollama-endpoint"] = this.config.endpoint;
}
return headers;
}
private async parseJsonResponse(response: Response): Promise<any> {
const text = await response.text();
if (!text) return null;
return JSON.parse(text);
}
async chatCompletion(
messages: ChatMessage[],
model: string = "gpt-oss:120b",
stream: boolean = false
): Promise<APIResponse<string>> {
try {
if (!this.config.apiKey) {
throw new Error("API key is required. Please configure your Ollama API key in settings.");
}
console.log("[Ollama] API call:", { endpoint: this.config.endpoint, model, messages });
const response = await fetch(`${this.config.endpoint}/chat`, {
const response = await fetch(LOCAL_CHAT_URL, {
method: "POST",
headers: this.getHeaders(),
headers: this.getHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({
model,
messages,
@@ -56,24 +95,23 @@ export class OllamaCloudService {
}),
});
console.log("[Ollama] Response status:", response.status, response.statusText);
if (!response.ok) {
const errorText = await response.text();
console.error("[Ollama] Error response:", errorText);
throw new Error(`Chat completion failed (${response.status}): ${response.statusText} - ${errorText}`);
const errorBody = await response.text();
throw new Error(
`Chat completion failed (${response.status}): ${response.statusText} - ${errorBody}`
);
}
const data = await response.json();
console.log("[Ollama] Response data:", data);
if (data.message && data.message.content) {
const data = await this.parseJsonResponse(response);
if (data?.message?.content) {
return { success: true, data: data.message.content };
} else if (data.choices && data.choices[0]) {
return { success: true, data: data.choices[0].message.content };
} else {
return { success: false, error: "Unexpected response format" };
}
if (data?.choices?.[0]?.message?.content) {
return { success: true, data: data.choices[0].message.content };
}
return { success: false, error: "Unexpected response format" };
} catch (error) {
console.error("[Ollama] Chat completion error:", error);
return {
@@ -85,32 +123,31 @@ export class OllamaCloudService {
async listModels(): Promise<APIResponse<string[]>> {
try {
if (this.config.apiKey) {
console.log("[Ollama] Listing models from:", `${this.config.endpoint}/tags`);
const response = await fetch(LOCAL_MODELS_URL, {
headers: this.getHeaders(),
});
const response = await fetch(`${this.config.endpoint}/tags`, {
headers: this.getHeaders(),
});
console.log("[Ollama] List models response status:", response.status, response.statusText);
if (!response.ok) {
throw new Error(`Failed to list models: ${response.statusText}`);
}
const data = await response.json();
console.log("[Ollama] Models data:", data);
const models = data.models?.map((m: OllamaModel) => m.name) || [];
this.availableModels = models;
return { success: true, data: models };
} else {
console.log("[Ollama] No API key, using fallback models");
return { success: true, data: ["gpt-oss:120b", "llama3.1", "gemma3", "deepseek-r1", "qwen3"] };
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`List models failed: ${response.statusText} - ${errorBody}`);
}
const data = await this.parseJsonResponse(response);
const models: string[] = Array.isArray(data?.models) ? data.models : [];
if (models.length === 0) {
this.availableModels = DEFAULT_MODELS;
return { success: true, data: DEFAULT_MODELS };
}
this.availableModels = models;
return { success: true, data: models };
} catch (error) {
console.error("[Ollama] listModels error:", error);
if (DEFAULT_MODELS.length > 0) {
this.availableModels = DEFAULT_MODELS;
return { success: true, data: DEFAULT_MODELS };
}
return {
success: false,
error: error instanceof Error ? error.message : "Failed to list models",
@@ -119,84 +156,7 @@ export class OllamaCloudService {
}
getAvailableModels(): string[] {
return this.availableModels.length > 0
? this.availableModels
: ["gpt-oss:120b", "llama3.1", "gemma3", "deepseek-r1", "qwen3"];
}
async enhancePrompt(prompt: string, model?: string): Promise<APIResponse<string>> {
const systemMessage: ChatMessage = {
role: "system",
content: `You are an expert prompt engineer. Your task is to enhance user prompts to make them more precise, actionable, and effective for AI coding agents.
Apply these principles:
1. Add specific context about project and requirements
2. Clarify constraints and preferences
3. Define expected output format clearly
4. Include edge cases and error handling requirements
5. Specify testing and validation criteria
Return ONLY the enhanced prompt, no explanations.`,
};
const userMessage: ChatMessage = {
role: "user",
content: `Enhance this prompt for an AI coding agent:\n\n${prompt}`,
};
return this.chatCompletion([systemMessage, userMessage], model || "gpt-oss:120b");
}
async generatePRD(idea: string, model?: string): Promise<APIResponse<string>> {
const systemMessage: ChatMessage = {
role: "system",
content: `You are an expert product manager and technical architect. Generate a comprehensive Product Requirements Document (PRD) based on user's idea.
Structure your PRD with these sections:
1. Overview & Objectives
2. User Personas & Use Cases
3. Functional Requirements (prioritized)
4. Non-functional Requirements
5. Technical Architecture Recommendations
6. Success Metrics & KPIs
Use clear, specific language suitable for development teams.`,
};
const userMessage: ChatMessage = {
role: "user",
content: `Generate a PRD for this idea:\n\n${idea}`,
};
return this.chatCompletion([systemMessage, userMessage], model || "gpt-oss:120b");
}
async generateActionPlan(prd: string, model?: string): Promise<APIResponse<string>> {
const systemMessage: ChatMessage = {
role: "system",
content: `You are an expert technical lead and project manager. Generate a detailed action plan based on PRD.
Structure of action plan with:
1. Task breakdown with priorities (High/Medium/Low)
2. Dependencies between tasks
3. Estimated effort for each task
4. Recommended frameworks and technologies
5. Architecture guidelines and best practices
Include specific recommendations for:
- Frontend frameworks
- Backend architecture
- Database choices
- Authentication/authorization
- Deployment strategy`,
};
const userMessage: ChatMessage = {
role: "user",
content: `Generate an action plan based on this PRD:\n\n${prd}`,
};
return this.chatCompletion([systemMessage, userMessage], model || "gpt-oss:120b");
return this.availableModels.length > 0 ? this.availableModels : DEFAULT_MODELS;
}
}

View File

@@ -1,85 +1,391 @@
import type { ChatMessage, APIResponse } from "@/types";
const DEFAULT_QWEN_ENDPOINT = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
const TOKEN_STORAGE_KEY = "promptarch-qwen-tokens";
function getOAuthBaseUrl(): string {
if (typeof window !== "undefined") {
return `${window.location.origin}/api/qwen`;
}
if (process.env.NEXT_PUBLIC_SITE_URL) {
return `${process.env.NEXT_PUBLIC_SITE_URL}/api/qwen`;
}
return "/api/qwen";
}
export interface QwenOAuthConfig {
apiKey?: string;
endpoint?: string;
oauthBaseUrl?: string;
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
endpoint?: string;
clientId?: string;
redirectUri?: string;
resourceUrl?: string;
}
export interface QwenOAuthToken {
accessToken: string;
refreshToken?: string;
expiresAt?: number;
resourceUrl?: string;
}
export interface QwenDeviceAuthorization {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
expires_in: number;
interval?: number;
}
export class QwenOAuthService {
private config: QwenOAuthConfig;
private endpoint: string;
private oauthBaseUrl: string;
private apiKey?: string;
private token: QwenOAuthToken | null = null;
private storageHydrated = false;
constructor(config: QwenOAuthConfig = {}) {
this.config = {
endpoint: config.endpoint || "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
apiKey: config.apiKey || process.env.QWEN_API_KEY,
accessToken: config.accessToken,
refreshToken: config.refreshToken,
expiresAt: config.expiresAt,
clientId: config.clientId || process.env.NEXT_PUBLIC_QWEN_CLIENT_ID,
redirectUri: config.redirectUri || (typeof window !== "undefined" ? window.location.origin : ""),
};
}
this.endpoint = config.endpoint || DEFAULT_QWEN_ENDPOINT;
this.oauthBaseUrl = config.oauthBaseUrl || getOAuthBaseUrl();
this.apiKey = config.apiKey || process.env.QWEN_API_KEY || undefined;
private getHeaders(): Record<string, string> {
const authHeader = this.config.accessToken
? `Bearer ${this.config.accessToken}`
: `Bearer ${this.config.apiKey}`;
return {
"Content-Type": "application/json",
"Authorization": authHeader,
};
}
isAuthenticated(): boolean {
return !!(this.config.apiKey || (this.config.accessToken && (!this.config.expiresAt || this.config.expiresAt > Date.now())));
}
getAccessToken(): string | null {
return this.config.accessToken || this.config.apiKey || null;
}
async authenticate(apiKey: string): Promise<APIResponse<string>> {
try {
this.config.apiKey = apiKey;
this.config.accessToken = undefined; // Clear OAuth token if API key is provided
return { success: true, data: "Authenticated successfully" };
} catch (error) {
console.error("Qwen authentication error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Authentication failed",
};
if (config.accessToken) {
this.setOAuthTokens({
accessToken: config.accessToken,
refreshToken: config.refreshToken,
expiresAt: config.expiresAt,
resourceUrl: config.resourceUrl,
});
}
}
setOAuthTokens(accessToken: string, refreshToken?: string, expiresIn?: number): void {
this.config.accessToken = accessToken;
if (refreshToken) this.config.refreshToken = refreshToken;
if (expiresIn) this.config.expiresAt = Date.now() + expiresIn * 1000;
/**
* Update the API key used for non-OAuth calls.
*/
setApiKey(apiKey: string) {
this.apiKey = apiKey;
}
getAuthorizationUrl(): string {
const baseUrl = "https://dashscope.console.aliyun.com/oauth/authorize"; // Placeholder URL
const params = new URLSearchParams({
client_id: this.config.clientId || "",
redirect_uri: this.config.redirectUri || "",
response_type: "code",
scope: "dashscope:chat",
/**
* Build default headers for Qwen completions (includes OAuth token refresh).
*/
private async getRequestHeaders(): Promise<Record<string, string>> {
const token = await this.getValidToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token?.accessToken) {
headers["Authorization"] = `Bearer ${token.accessToken}`;
return headers;
}
if (this.apiKey) {
headers["Authorization"] = `Bearer ${this.apiKey}`;
return headers;
}
throw new Error("Please configure a Qwen API key or authenticate via OAuth.");
}
/**
* Determine the effective API endpoint (uses token-specific resource_url if available).
*/
private getEffectiveEndpoint(): string {
const resourceUrl = this.token?.resourceUrl;
if (resourceUrl) {
return this.normalizeResourceUrl(resourceUrl);
}
return this.endpoint;
}
private normalizeResourceUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return this.endpoint;
}
const withProtocol = trimmed.startsWith("http") ? trimmed : `https://${trimmed}`;
const cleaned = withProtocol.replace(/\/$/, "");
return cleaned.endsWith("/v1") ? cleaned : `${cleaned}/v1`;
}
private hydrateTokens() {
if (this.storageHydrated || typeof window === "undefined" || typeof window.localStorage === "undefined") {
return;
}
try {
const stored = window.localStorage.getItem(TOKEN_STORAGE_KEY);
if (stored) {
this.token = JSON.parse(stored);
}
} catch (error) {
console.warn("[QwenOAuth] Failed to read tokens from localStorage:", error);
this.token = null;
} finally {
this.storageHydrated = true;
}
}
private getStoredToken(): QwenOAuthToken | null {
this.hydrateTokens();
return this.token;
}
private persistToken(token: QwenOAuthToken | null) {
if (typeof window === "undefined" || typeof window.localStorage === "undefined") {
return;
}
try {
if (token) {
window.localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(token));
} else {
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
}
} catch (error) {
console.warn("[QwenOAuth] Failed to persist tokens to localStorage:", error);
}
}
private isTokenExpired(token: QwenOAuthToken): boolean {
if (!token.expiresAt) {
return false;
}
return Date.now() >= token.expiresAt - 60_000;
}
/**
* Refreshes the OAuth token using the stored refresh token.
*/
private async refreshToken(refreshToken: string): Promise<QwenOAuthToken> {
const response = await fetch(`${this.oauthBaseUrl}/oauth/refresh`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
return `${baseUrl}?${params.toString()}`;
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || "Failed to refresh Qwen token");
}
const data = await response.json();
return this.parseTokenResponse(data);
}
async logout(): Promise<void> {
this.config.apiKey = undefined;
this.config.accessToken = undefined;
this.config.refreshToken = undefined;
this.config.expiresAt = undefined;
/**
* Returns a valid token, refreshing if necessary.
*/
private async getValidToken(): Promise<QwenOAuthToken | null> {
const token = this.getStoredToken();
if (!token) {
return null;
}
if (this.isTokenExpired(token)) {
if (token.refreshToken) {
try {
const refreshed = await this.refreshToken(token.refreshToken);
this.setOAuthTokens(refreshed);
return refreshed;
} catch (error) {
console.error("Qwen token refresh failed", error);
this.setOAuthTokens(undefined);
return null;
}
}
this.setOAuthTokens(undefined);
return null;
}
return token;
}
/**
* Sign out the OAuth session.
*/
signOut(): void {
this.setOAuthTokens(undefined);
}
/**
* Stores OAuth tokens locally.
*/
setOAuthTokens(tokens?: QwenOAuthToken | null) {
if (!tokens) {
this.token = null;
this.persistToken(null);
this.storageHydrated = true;
return;
}
this.token = tokens;
this.persistToken(tokens);
this.storageHydrated = true;
}
getTokenInfo(): QwenOAuthToken | null {
return this.getStoredToken();
}
/**
* Perform the OAuth device flow to obtain tokens.
*/
async signIn(): Promise<QwenOAuthToken> {
if (typeof window === "undefined") {
throw new Error("Qwen OAuth is only supported in the browser");
}
const popup = window.open(
"",
"qwen-oauth",
"width=500,height=600,scrollbars=yes,resizable=yes"
);
const codeVerifier = this.generateCodeVerifier();
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
const deviceAuth = await this.requestDeviceAuthorization(codeChallenge);
if (popup) {
try {
popup.location.href = deviceAuth.verification_uri_complete;
} catch {
// ignore cross-origin restrictions
}
} else {
window.alert(
`Open this URL to authenticate:\n${deviceAuth.verification_uri_complete}\n\nUser code: ${deviceAuth.user_code}`
);
}
const expiresAt = Date.now() + deviceAuth.expires_in * 1000;
let pollInterval = 2000;
while (Date.now() < expiresAt) {
const tokenData = await this.pollDeviceToken(deviceAuth.device_code, codeVerifier);
if (tokenData?.access_token) {
const token = this.parseTokenResponse(tokenData);
this.setOAuthTokens(token);
popup?.close();
return token;
}
if (tokenData?.error === "authorization_pending") {
await this.delay(pollInterval);
continue;
}
if (tokenData?.error === "slow_down") {
pollInterval = Math.min(Math.ceil(pollInterval * 1.5), 10000);
await this.delay(pollInterval);
continue;
}
throw new Error(tokenData?.error_description || tokenData?.error || "OAuth failed");
}
throw new Error("Qwen OAuth timed out");
}
async fetchUserInfo(): Promise<unknown> {
const token = await this.getValidToken();
if (!token?.accessToken) {
throw new Error("Not authenticated");
}
const response = await fetch(`${this.oauthBaseUrl}/user`, {
headers: {
Authorization: `Bearer ${token.accessToken}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || "Failed to fetch user info");
}
return await response.json();
}
private async requestDeviceAuthorization(codeChallenge: string): Promise<QwenDeviceAuthorization> {
const response = await fetch(`${this.oauthBaseUrl}/oauth/device`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
code_challenge: codeChallenge,
code_challenge_method: "S256",
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || "Device authorization failed");
}
return await response.json();
}
private async pollDeviceToken(deviceCode: string, codeVerifier: string): Promise<any> {
const response = await fetch(`${this.oauthBaseUrl}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
device_code: deviceCode,
code_verifier: codeVerifier,
}),
});
return await response.json();
}
private delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private parseTokenResponse(data: any): QwenOAuthToken {
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
resourceUrl: data.resource_url,
expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined,
};
}
/**
* Generate a PKCE code verifier.
*/
private generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return this.toBase64Url(array);
}
/**
* Generate a PKCE code challenge.
*/
private async generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
return this.toBase64Url(new Uint8Array(digest));
}
private toBase64Url(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async chatCompletion(
@@ -88,15 +394,12 @@ export class QwenOAuthService {
stream: boolean = false
): Promise<APIResponse<string>> {
try {
if (!this.config.apiKey) {
throw new Error("API key is required. Please configure your Qwen API key in settings.");
}
const headers = await this.getRequestHeaders();
const url = `${this.getEffectiveEndpoint()}/chat/completions`;
console.log("[Qwen] API call:", { endpoint: this.config.endpoint, model, messages });
const response = await fetch(`${this.config.endpoint}/chat/completions`, {
const response = await fetch(url, {
method: "POST",
headers: this.getHeaders(),
headers,
body: JSON.stringify({
model,
messages,
@@ -104,22 +407,17 @@ export class QwenOAuthService {
}),
});
console.log("[Qwen] Response status:", response.status, response.statusText);
if (!response.ok) {
const errorText = await response.text();
console.error("[Qwen] Error response:", errorText);
throw new Error(`Chat completion failed (${response.status}): ${response.statusText} - ${errorText}`);
}
const data = await response.json();
console.log("[Qwen] Response data:", data);
if (data.choices && data.choices[0] && data.choices[0].message) {
if (data.choices?.[0]?.message) {
return { success: true, data: data.choices[0].message.content };
} else {
return { success: false, error: "Unexpected response format" };
}
return { success: false, error: "Unexpected response format" };
} catch (error) {
console.error("[Qwen] Chat completion error:", error);
return {
@@ -204,14 +502,95 @@ Include specific recommendations for:
return this.chatCompletion([systemMessage, userMessage], model || "qwen-coder-plus");
}
async generateUXDesignerPrompt(appDescription: string, model?: string): Promise<APIResponse<string>> {
const systemMessage: ChatMessage = {
role: "system",
content: `You are a world-class UX/UI designer with deep expertise in human-centered design principles, user research, interaction design, visual design systems, and modern design tools (Figma, Sketch, Adobe XD).
Your task is to create an exceptional, detailed prompt for generating best possible UX design for a given app description.
Generate a comprehensive UX design prompt that includes:
1. USER RESEARCH & PERSONAS
- Primary target users and their motivations
- User pain points and needs
- User journey maps
- Persona archetypes with demographics and goals
2. INFORMATION ARCHITECTURE
- Content hierarchy and organization
- Navigation structure and patterns
- User flows and key pathways
- Site map or app structure
3. VISUAL DESIGN SYSTEM
- Color palette recommendations (primary, secondary, accent, neutral)
- Typography hierarchy and font pairings
- Component library approach
- Spacing, sizing, and layout grids
- Iconography style and set
4. INTERACTION DESIGN
- Micro-interactions and animations
- Gesture patterns for touch interfaces
- Loading states and empty states
- Error handling and feedback mechanisms
- Accessibility considerations (WCAG compliance)
5. KEY SCREENS & COMPONENTS
- Core screens that need detailed design
- Critical components (buttons, forms, cards, navigation)
- Data visualization needs
- Responsive design requirements (mobile, tablet, desktop)
6. DESIGN DELIVERABLES
- Wireframes vs. high-fidelity mockups
- Design system documentation needs
- Prototyping requirements
- Handoff specifications for developers
7. COMPETITIVE INSIGHTS
- Design patterns from successful apps in this category
- Opportunities to differentiate
- Modern design trends to consider
The output should be a detailed, actionable prompt that a designer or AI image generator can use to create world-class UX designs.
Make's prompt specific, inspiring, and comprehensive. Use professional UX terminology.`,
};
const userMessage: ChatMessage = {
role: "user",
content: `Create a BEST EVER UX design prompt for this app:\n\n${appDescription}`,
};
return this.chatCompletion([systemMessage, userMessage], model || "qwen-coder-plus");
}
async listModels(): Promise<APIResponse<string[]>> {
const models = ["qwen-coder-plus", "qwen-coder-turbo", "qwen-coder-lite", "qwen-plus", "qwen-turbo", "qwen-max"];
const models = [
"qwen-coder-plus",
"qwen-coder-turbo",
"qwen-coder-lite",
"qwen-plus",
"qwen-turbo",
"qwen-max",
];
return { success: true, data: models };
}
getAvailableModels(): string[] {
return ["qwen-coder-plus", "qwen-coder-turbo", "qwen-coder-lite", "qwen-plus", "qwen-turbo", "qwen-max"];
return [
"qwen-coder-plus",
"qwen-coder-turbo",
"qwen-coder-lite",
"qwen-plus",
"qwen-turbo",
"qwen-max",
];
}
}
export default QwenOAuthService;
const qwenOAuthService = new QwenOAuthService();
export default qwenOAuthService;
export { qwenOAuthService };

View File

@@ -182,6 +182,71 @@ Include specific recommendations for:
getAvailableModels(): string[] {
return ["glm-4.7", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4-flash", "glm-4-flashx"];
}
async generateUXDesignerPrompt(appDescription: string, model?: string): Promise<APIResponse<string>> {
const systemMessage: ChatMessage = {
role: "system",
content: `You are a world-class UX/UI designer with deep expertise in human-centered design principles, user research, interaction design, visual design systems, and modern design tools (Figma, Sketch, Adobe XD).
Your task is to create an exceptional, detailed prompt for generating the best possible UX design for a given app description.
Generate a comprehensive UX design prompt that includes:
1. USER RESEARCH & PERSONAS
- Primary target users and their motivations
- User pain points and needs
- User journey maps
- Persona archetypes with demographics and goals
2. INFORMATION ARCHITECTURE
- Content hierarchy and organization
- Navigation structure and patterns
- User flows and key pathways
- Site map or app structure
3. VISUAL DESIGN SYSTEM
- Color palette recommendations (primary, secondary, accent, neutral)
- Typography hierarchy and font pairings
- Component library approach
- Spacing, sizing, and layout grids
- Iconography style and set
4. INTERACTION DESIGN
- Micro-interactions and animations
- Gesture patterns for touch interfaces
- Loading states and empty states
- Error handling and feedback mechanisms
- Accessibility considerations (WCAG compliance)
5. KEY SCREENS & COMPONENTS
- Core screens that need detailed design
- Critical components (buttons, forms, cards, navigation)
- Data visualization needs
- Responsive design requirements (mobile, tablet, desktop)
6. DESIGN DELIVERABLES
- Wireframes vs. high-fidelity mockups
- Design system documentation needs
- Prototyping requirements
- Handoff specifications for developers
7. COMPETITIVE INSIGHTS
- Design patterns from successful apps in this category
- Opportunities to differentiate
- Modern design trends to consider
The output should be a detailed, actionable prompt that a designer or AI image generator can use to create world-class UX designs.
Make the prompt specific, inspiring, and comprehensive. Use professional UX terminology.`,
};
const userMessage: ChatMessage = {
role: "user",
content: `Create the BEST EVER UX design prompt for this app:\n\n${appDescription}`,
};
return this.chatCompletion([systemMessage, userMessage], model || "glm-4.7", true);
}
}
export default ZaiPlanService;

View File

@@ -14,7 +14,7 @@ interface AppState {
accessToken: string;
refreshToken?: string;
expiresAt?: number;
};
} | null;
isProcessing: boolean;
error: string | null;
history: {
@@ -31,7 +31,7 @@ interface AppState {
setSelectedModel: (provider: ModelProvider, model: string) => void;
setAvailableModels: (provider: ModelProvider, models: string[]) => void;
setApiKey: (provider: ModelProvider, key: string) => void;
setQwenTokens: (tokens: { accessToken: string; refreshToken?: string; expiresAt?: number }) => void;
setQwenTokens: (tokens?: { accessToken: string; refreshToken?: string; expiresAt?: number } | null) => void;
setProcessing: (processing: boolean) => void;
setError: (error: string | null) => void;
addToHistory: (prompt: string) => void;

View File

@@ -2,5 +2,11 @@
"buildCommand": "npm run build",
"outputDirectory": ".next",
"framework": "nextjs",
"devCommand": "npm run dev"
"devCommand": "npm run dev",
"env": {
"NEXT_PUBLIC_SITE_URL": {
"description": "The production URL of your app (e.g., https://your-app.vercel.app)",
"value": "https://your-app.vercel.app"
}
}
}