diff --git a/app/api/qwen/constants.ts b/app/api/qwen/constants.ts new file mode 100644 index 0000000..403294a --- /dev/null +++ b/app/api/qwen/constants.ts @@ -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"; diff --git a/app/api/qwen/oauth/device/route.ts b/app/api/qwen/oauth/device/route.ts new file mode 100644 index 0000000..b304051 --- /dev/null +++ b/app/api/qwen/oauth/device/route.ts @@ -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 } + ); + } +} diff --git a/app/api/qwen/oauth/refresh/route.ts b/app/api/qwen/oauth/refresh/route.ts new file mode 100644 index 0000000..3bd1d38 --- /dev/null +++ b/app/api/qwen/oauth/refresh/route.ts @@ -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 } + ); + } +} diff --git a/app/api/qwen/oauth/token/route.ts b/app/api/qwen/oauth/token/route.ts new file mode 100644 index 0000000..13646c1 --- /dev/null +++ b/app/api/qwen/oauth/token/route.ts @@ -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 } + ); + } +} diff --git a/app/api/qwen/user/route.ts b/app/api/qwen/user/route.ts new file mode 100644 index 0000000..ccb5870 --- /dev/null +++ b/app/api/qwen/user/route.ts @@ -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 } + ); + } +} diff --git a/components/SettingsPanel.tsx b/components/SettingsPanel.tsx index 63652c6..15e4c05 100644 --- a/components/SettingsPanel.tsx +++ b/components/SettingsPanel.tsx @@ -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>({}); + 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)"} {qwenTokens && ( diff --git a/lib/services/model-adapter.ts b/lib/services/model-adapter.ts index 6bc05c8..c41e9c6 100644 --- a/lib/services/model-adapter.ts +++ b/lib/services/model-adapter.ts @@ -1,8 +1,10 @@ import type { ModelProvider, APIResponse, ChatMessage } from "@/types"; import OllamaCloudService from "./ollama-cloud"; import ZaiPlanService from "./zai-plan"; +import qwenOAuthService, { QwenOAuthConfig, QwenOAuthToken } from "./qwen-oauth"; export interface ModelAdapterConfig { + qwen?: QwenOAuthConfig; ollama?: { apiKey?: string; endpoint?: string; @@ -17,12 +19,27 @@ export interface ModelAdapterConfig { export class ModelAdapter { private ollamaService: OllamaCloudService; private zaiService: ZaiPlanService; + private qwenService = qwenOAuthService; private preferredProvider: ModelProvider; 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 { @@ -37,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 { + return await this.qwenService.signIn(); + } + + getQwenTokenInfo(): QwenOAuthToken | null { + return this.qwenService.getTokenInfo(); + } + + private buildFallbackProviders(...providers: ModelProvider[]): ModelProvider[] { + const seen = new Set(); + return providers.filter((provider) => { + if (seen.has(provider)) { + return false; + } + seen.add(provider); + return true; + }); + } + private async callWithFallback( operation: (service: any) => Promise>, providers: ModelProvider[] @@ -46,6 +90,9 @@ export class ModelAdapter { let service: any; switch (provider) { + case "qwen": + service = this.qwenService; + break; case "ollama": service = this.ollamaService; break; @@ -70,22 +117,26 @@ export class ModelAdapter { } async enhancePrompt(prompt: string, provider?: ModelProvider, model?: string): Promise> { - 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> { - 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> { - 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> { - 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.generateUXDesignerPrompt(appDescription, model), providers); } @@ -98,6 +149,9 @@ export class ModelAdapter { let service: any; switch (provider) { + case "qwen": + service = this.qwenService; + break; case "ollama": service = this.ollamaService; break; @@ -117,6 +171,7 @@ export class ModelAdapter { async listModels(provider?: ModelProvider): Promise>> { const fallbackModels: Record = { + 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"], }; @@ -148,6 +203,8 @@ export class ModelAdapter { getAvailableModels(provider: ModelProvider): string[] { switch (provider) { + case "qwen": + return this.qwenService.getAvailableModels(); case "ollama": return this.ollamaService.getAvailableModels(); case "zai": diff --git a/lib/services/ollama-cloud.ts b/lib/services/ollama-cloud.ts index 84452ee..b431794 100644 --- a/lib/services/ollama-cloud.ts +++ b/lib/services/ollama-cloud.ts @@ -100,10 +100,18 @@ export class OllamaCloudService { const data = await response.json(); console.log("[Ollama] Models data:", data); - const models = data.models?.map((m: OllamaModel) => m.name) || []; - + + let models: string[] = []; + if (Array.isArray(data.models)) { + models = data.models.map((m: OllamaModel) => m.name); + } else if (Array.isArray(data)) { + models = data.map((m: OllamaModel) => m.name); + } else if (data.model) { + models = [data.model.name]; + } + this.availableModels = models; - + return { success: true, data: models }; } else { console.log("[Ollama] No API key, using fallback models"); diff --git a/lib/services/qwen-oauth.ts b/lib/services/qwen-oauth.ts index e5204b6..1b69c9a 100644 --- a/lib/services/qwen-oauth.ts +++ b/lib/services/qwen-oauth.ts @@ -1,85 +1,371 @@ import type { ChatMessage, APIResponse } from "@/types"; +const DEFAULT_QWEN_ENDPOINT = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; +const DEFAULT_OAUTH_BASE = "/api/qwen"; +const TOKEN_STORAGE_KEY = "promptarch-qwen-tokens"; + 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 || DEFAULT_OAUTH_BASE; + this.apiKey = config.apiKey || process.env.QWEN_API_KEY || undefined; - private getHeaders(): Record { - 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> { - 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> { + const token = await this.getValidToken(); + const headers: Record = { + "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") { + return; + } + + try { + const stored = window.localStorage.getItem(TOKEN_STORAGE_KEY); + if (stored) { + this.token = JSON.parse(stored); + } + } catch { + 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") { + return; + } + + if (token) { + window.localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(token)); + } else { + window.localStorage.removeItem(TOKEN_STORAGE_KEY); + } + } + + 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 { + 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 { - 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 { + 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.clearTokens(); + return null; + } + } + this.clearTokens(); + return null; + } + + return token; + } + + /** + * Sign out the OAuth session. + */ + signOut(): void { + this.clearTokens(); + } + + /** + * Stores OAuth tokens locally. + */ + setOAuthTokens(tokens?: QwenOAuthToken) { + 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 { + if (typeof window === "undefined") { + throw new Error("Qwen OAuth is only supported in the browser"); + } + + const codeVerifier = this.generateCodeVerifier(); + const codeChallenge = await this.generateCodeChallenge(codeVerifier); + const deviceAuth = await this.requestDeviceAuthorization(codeChallenge); + + const popup = window.open( + deviceAuth.verification_uri_complete, + "qwen-oauth", + "width=500,height=600,scrollbars=yes,resizable=yes" + ); + + if (!popup) { + 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 { + 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 { + 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 { + 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 { + 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 +374,12 @@ export class QwenOAuthService { stream: boolean = false ): Promise> { 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 +387,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 +482,95 @@ Include specific recommendations for: return this.chatCompletion([systemMessage, userMessage], model || "qwen-coder-plus"); } + async generateUXDesignerPrompt(appDescription: string, model?: string): Promise> { + 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> { - 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 }; diff --git a/lib/store.ts b/lib/store.ts index 9b4798c..5551789 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -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;