Compare commits

...

10 Commits

14 changed files with 3034 additions and 77 deletions

View File

@@ -107,6 +107,14 @@ Advanced automation capabilities with new robust connectivity:
- **Competitive Intelligence** - AI researches top competitors for design inspiration - **Competitive Intelligence** - AI researches top competitors for design inspiration
- **Mobile-First** - All generated code is responsive by default - **Mobile-First** - All generated code is responsive by default
### 📦 UX Package Generator (New!)
Export your entire frontend (HTML/CSS/JS) into a single, portable `ux_package.json` file.
- **Portable Payload** - Perfect for loading into other AI coding agents (Gemini, ChatGPT, Claude) to build the backend logic.
- **One-Click Export** - Instantly package your project's frontend artifacts and instructions.
- **Backend-Ready** - Includes specific prompts instructing the AI how to implement the server-side logic based on your frontend.
--- ---
## 🛠️ System Requirements ## 🛠️ System Requirements
@@ -176,19 +184,33 @@ Access **state-of-the-art open-weight models** for FREE via Ollama Cloud:
#### 🚀 Available Free Models: #### 🚀 Available Free Models:
| Model | Size | Best For | | Model | Size | Category | Best For |
|-------|------|----------| |-------|------|----------|----------|
| **GPT-OSS 120B** | 120B | OpenAI's open-weight reasoning model | | **GPT-OSS 120B** | 120B | Flagship | OpenAI's open-weight reasoning model |
| **DeepSeek V3.2** | MoE | Superior reasoning & agent performance | | **DeepSeek V3.2** | MoE | Flagship | Superior reasoning & agent performance |
| **Gemini 3 Pro Preview** | Cloud | Google's SOTA reasoning model | | **DeepSeek V3.1** | 671B | Flagship | Hybrid thinking/non-thinking mode |
| **Qwen3 Coder 480B** | 480B | Agentic coding, long context | | **Gemini 3 Pro Preview** | Cloud | Flagship | Google's most intelligent model with SOTA reasoning |
| **Devstral 2 123B** | 123B | Multi-file editing, software agents | | **Gemini 3 Flash Preview** | Cloud | Fast | Frontier intelligence built for speed |
| **Kimi K2** | MoE | State-of-the-art coding agent tasks | | **Qwen3 Coder 480B** | 480B | Coding | Agentic coding, long context |
| **Qwen3 VL 235B** | 235B | Vision + language understanding | | **Qwen3 Coder 30B** | 30B | Coding | Agentic coding model |
| **Gemini 3 Flash** | Cloud | Fast, frontier intelligence | | **Devstral 2 123B** | 123B | Coding | Multi-file editing, software agents |
| **Ministral 3** | 3-14B | Edge deployment, fast responses | | **Devstral Small 2 24B** | 24B | Coding | Vision + tools for software engineering agents |
| **RNJ-1 8B** | 8B | Coding | Essential AI model optimized for code and STEM |
| **Qwen3 Next 80B** | 80B | Reasoning | Strong parameter efficiency and inference speed |
| **Kimi K2** | MoE | Reasoning | State-of-the-art coding agent tasks |
| **Kimi K2 Thinking** | MoE | Reasoning | Moonshot AI's best open-source thinking model |
| **Cogito 2.1** | 671B | Reasoning | Instruction tuned generative model |
| **Qwen3 VL 235B** | 235B | Vision | Most powerful vision-language model in Qwen family |
| **Qwen3 VL 32B** | 32B | Vision | Powerful vision-language understanding |
| **Gemma 3 27B** | 27B | Vision | Most capable model that runs on a single GPU |
| **Ministral 3 14B** | 14B | Fast | Edge deployment, fast responses |
| **Ministral 3 8B** | 8B | Fast | Edge deployment with vision + tools |
| **Nemotron 3 Nano** | Nano | Fast | Efficient, open, and intelligent agentic model |
| **GLM 4.6** | Large | Flagship | Advanced agentic, reasoning and coding |
| **MiniMax M2** | Large | Flagship | High-efficiency LLM for coding agents |
| **Mistral Large 3** | MoE | Flagship | Multimodal MoE for production-grade tasks |
...and many more! Open the **AI Model Manager** in Goose Ultra to see all available models. ...and more! Open the **AI Model Manager** in Goose Ultra to see the full list.
#### 📖 Ollama Cloud Docs: #### 📖 Ollama Cloud Docs:
- API Documentation: [docs.ollama.com/cloud](https://docs.ollama.com/cloud) - API Documentation: [docs.ollama.com/cloud](https://docs.ollama.com/cloud)

View File

@@ -9,6 +9,10 @@ import * as viAutomation from './vi-automation.js';
import { execFile } from 'child_process'; import { execFile } from 'child_process';
import { promisify } from 'util'; import { promisify } from 'util';
// User Authentication & Qwen OAuth
import * as userData from './user-data.js';
import * as qwenOAuth from './qwen-oauth.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -703,4 +707,168 @@ ipcMain.handle('vi-open-browser', async (_, { url }) => {
return await viAutomation.openBrowser(url); return await viAutomation.openBrowser(url);
}); });
// ============================================
// USER AUTHENTICATION SYSTEM
// ============================================
// Get secret questions list
ipcMain.handle('user-get-secret-questions', () => {
return userData.SECRET_QUESTIONS;
});
// Create a new user account
ipcMain.handle('user-create', async (_, { displayName, questionId, answer }) => {
try {
const { user, secretCode } = userData.createUser(displayName, questionId, answer);
// Auto-start session for new user
const session = userData.startSession(user);
return { success: true, user, secretCode, session };
} catch (error) {
console.error('[UserAuth] Create user failed:', error);
return { success: false, error: error.message };
}
});
// Authenticate user with secret code
ipcMain.handle('user-login', async (_, { secretCode }) => {
try {
const user = userData.authenticateUser(secretCode);
if (user) {
const session = userData.startSession(user);
return { success: true, user, session };
}
return { success: false, error: 'Invalid secret code' };
} catch (error) {
console.error('[UserAuth] Login failed:', error);
return { success: false, error: error.message };
}
});
// Get current session
ipcMain.handle('user-get-session', () => {
return userData.getCurrentSession();
});
// Logout (end session)
ipcMain.handle('user-logout', async (_, { cleanData }) => {
try {
const session = userData.getCurrentSession();
if (cleanData && session?.userId) {
userData.cleanUserData(session.userId);
}
userData.endSession();
return { success: true };
} catch (error) {
console.error('[UserAuth] Logout failed:', error);
return { success: false, error: error.message };
}
});
// Get user statistics
ipcMain.handle('user-get-stats', async (_, { userId }) => {
try {
return userData.getUserStats(userId);
} catch (error) {
return { projectCount: 0, chatCount: 0, totalSizeBytes: 0, hasQwenTokens: false };
}
});
// Clean user data without logout
ipcMain.handle('user-clean-data', async (_, { userId }) => {
try {
userData.cleanUserData(userId);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// Get user's projects directory
ipcMain.handle('user-get-projects-dir', (_, { userId }) => {
return userData.getUserProjectsDir(userId);
});
// ============================================
// QWEN OAUTH (INLINE DEVICE FLOW)
// ============================================
// Start Qwen OAuth device flow
ipcMain.on('qwen-auth-start', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
if (!window || window.isDestroyed()) return;
const session = userData.getCurrentSession();
await qwenOAuth.startDeviceFlow(
// onProgress
(progress) => {
if (!window.isDestroyed()) {
window.webContents.send('qwen-auth-progress', progress);
}
},
// onSuccess
(credentials) => {
// Save tokens to user-specific location if logged in
if (session?.userId) {
qwenOAuth.saveUserTokens(session.userId, app.getPath('userData'), credentials);
} else {
// Fallback to legacy location for backward compatibility
qwenOAuth.saveLegacyTokens(credentials);
}
if (!window.isDestroyed()) {
window.webContents.send('qwen-auth-success', credentials);
}
},
// onError
(error) => {
if (!window.isDestroyed()) {
window.webContents.send('qwen-auth-error', error);
}
}
);
});
// Cancel ongoing Qwen OAuth
ipcMain.on('qwen-auth-cancel', () => {
qwenOAuth.cancelAuth();
});
// Get Qwen auth status for current user
ipcMain.handle('qwen-get-auth-status', () => {
const session = userData.getCurrentSession();
let tokens = null;
if (session?.userId) {
tokens = qwenOAuth.loadUserTokens(session.userId, app.getPath('userData'));
}
// Fallback to legacy tokens if not logged in or no user tokens
if (!tokens) {
tokens = qwenOAuth.loadLegacyTokens();
}
const isValid = tokens?.access_token &&
(!tokens.expiry_date || tokens.expiry_date > Date.now() + 30000);
return {
isAuthenticated: !!tokens?.access_token,
isValid,
expiresAt: tokens?.expiry_date || null
};
});
// Clear Qwen tokens for current user
ipcMain.handle('qwen-clear-tokens', () => {
const session = userData.getCurrentSession();
if (session?.userId) {
qwenOAuth.clearUserTokens(session.userId, app.getPath('userData'));
}
return { success: true };
});
console.log('Goose Ultra Electron Main Process Started'); console.log('Goose Ultra Electron Main Process Started');

View File

@@ -97,5 +97,64 @@ contextBridge.exposeInMainWorld('electron', {
getKeyStatus: () => ipcRenderer.invoke('ollama-get-key-status'), getKeyStatus: () => ipcRenderer.invoke('ollama-get-key-status'),
saveKey: (key) => ipcRenderer.invoke('ollama-save-key', { key }), saveKey: (key) => ipcRenderer.invoke('ollama-save-key', { key }),
getModels: () => ipcRenderer.invoke('ollama-get-models') getModels: () => ipcRenderer.invoke('ollama-get-models')
},
// ==========================================
// USER AUTHENTICATION SYSTEM
// ==========================================
user: {
// Get list of secret questions
getSecretQuestions: () => ipcRenderer.invoke('user-get-secret-questions'),
// Create a new account (returns { success, user, secretCode, session } or { success: false, error })
create: (displayName, questionId, answer) =>
ipcRenderer.invoke('user-create', { displayName, questionId, answer }),
// Login with secret code (returns { success, user, session } or { success: false, error })
login: (secretCode) => ipcRenderer.invoke('user-login', { secretCode }),
// Get current session (returns session object or null)
getSession: () => ipcRenderer.invoke('user-get-session'),
// Logout (optionally clean data)
logout: (cleanData = false) => ipcRenderer.invoke('user-logout', { cleanData }),
// Get user statistics
getStats: (userId) => ipcRenderer.invoke('user-get-stats', { userId }),
// Clean all user data (projects, chats, keys)
cleanData: (userId) => ipcRenderer.invoke('user-clean-data', { userId }),
// Get user's projects directory path
getProjectsDir: (userId) => ipcRenderer.invoke('user-get-projects-dir', { userId })
},
// ==========================================
// QWEN OAUTH (INLINE DEVICE FLOW)
// ==========================================
qwenAuth: {
// Start the device authorization flow (triggers browser open)
start: () => ipcRenderer.send('qwen-auth-start'),
// Cancel ongoing authorization
cancel: () => ipcRenderer.send('qwen-auth-cancel'),
// Get current auth status
getStatus: () => ipcRenderer.invoke('qwen-get-auth-status'),
// Clear saved tokens
clearTokens: () => ipcRenderer.invoke('qwen-clear-tokens'),
// Event listeners for auth flow
onProgress: (callback) => ipcRenderer.on('qwen-auth-progress', (_, data) => callback(data)),
onSuccess: (callback) => ipcRenderer.on('qwen-auth-success', (_, creds) => callback(creds)),
onError: (callback) => ipcRenderer.on('qwen-auth-error', (_, error) => callback(error)),
// Cleanup listeners
removeListeners: () => {
ipcRenderer.removeAllListeners('qwen-auth-progress');
ipcRenderer.removeAllListeners('qwen-auth-success');
ipcRenderer.removeAllListeners('qwen-auth-error');
}
} }
}); });

View File

@@ -0,0 +1,360 @@
/**
* Qwen OAuth2 Device Flow for Goose Ultra
*
* Implements RFC 8628 OAuth 2.0 Device Authorization Grant
* with PKCE (Proof Key for Code Exchange)
*
* Based on: qwen-code/packages/core/src/qwen/qwenOAuth2.ts
* License: Apache-2.0 (Qwen)
*/
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { shell } from 'electron';
// ===== OAUTH CONFIGURATION =====
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
// ===== PKCE UTILITIES (RFC 7636) =====
/**
* Generate a random code verifier for PKCE
* @returns A random string of 43-128 characters
*/
export function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
/**
* Generate a code challenge from a code verifier using SHA-256
* @param {string} codeVerifier
* @returns {string}
*/
export function generateCodeChallenge(codeVerifier) {
return crypto.createHash('sha256').update(codeVerifier).digest('base64url');
}
/**
* Generate PKCE code verifier and challenge pair
*/
export function generatePKCEPair() {
const code_verifier = generateCodeVerifier();
const code_challenge = generateCodeChallenge(code_verifier);
return { code_verifier, code_challenge };
}
// ===== HELPERS =====
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function objectToUrlEncoded(data) {
return Object.keys(data)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
.join('&');
}
// ===== CANCELLATION =====
let isCancelled = false;
export function cancelAuth() {
isCancelled = true;
}
// ===== MAIN DEVICE FLOW =====
/**
* Start the OAuth Device Authorization Flow
*
* @param {Function} onProgress - Callback for progress updates
* @param {Function} onSuccess - Callback with credentials on success
* @param {Function} onError - Callback with error message on failure
*/
export async function startDeviceFlow(onProgress, onSuccess, onError) {
isCancelled = false;
try {
// 1. Generate PKCE pair
const { code_verifier, code_challenge } = generatePKCEPair();
console.log('[QwenOAuth] Starting device flow with PKCE...');
// 2. Request device code
const deviceAuthBody = objectToUrlEncoded({
client_id: QWEN_OAUTH_CLIENT_ID,
scope: QWEN_OAUTH_SCOPE,
code_challenge,
code_challenge_method: 'S256'
});
const deviceAuthResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'x-request-id': crypto.randomUUID()
},
body: deviceAuthBody
});
if (!deviceAuthResponse.ok) {
const errorText = await deviceAuthResponse.text();
throw new Error(`Device authorization failed: ${deviceAuthResponse.status} - ${errorText}`);
}
const deviceAuth = await deviceAuthResponse.json();
console.log('[QwenOAuth] Device auth response:', {
user_code: deviceAuth.user_code,
expires_in: deviceAuth.expires_in
});
if (!deviceAuth.device_code || !deviceAuth.verification_uri_complete) {
throw new Error('Invalid device authorization response');
}
// 3. Notify UI and open browser
onProgress({
status: 'awaiting_auth',
url: deviceAuth.verification_uri_complete,
userCode: deviceAuth.user_code,
expiresIn: deviceAuth.expires_in
});
// Auto-open browser
try {
await shell.openExternal(deviceAuth.verification_uri_complete);
} catch (e) {
console.warn('[QwenOAuth] Failed to open browser:', e.message);
}
// 4. Poll for token
let pollInterval = 2000; // 2 seconds
const maxAttempts = Math.ceil(deviceAuth.expires_in / (pollInterval / 1000));
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (isCancelled) {
onError('Authentication cancelled by user');
return;
}
await sleep(pollInterval);
const tokenBody = objectToUrlEncoded({
grant_type: QWEN_OAUTH_GRANT_TYPE,
client_id: QWEN_OAUTH_CLIENT_ID,
device_code: deviceAuth.device_code,
code_verifier
});
try {
const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: tokenBody
});
const tokenData = await tokenResponse.json();
// Success case
if (tokenData.access_token) {
console.log('[QwenOAuth] Token obtained successfully!');
const credentials = {
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token || null,
token_type: tokenData.token_type || 'Bearer',
resource_url: tokenData.resource_url || null,
expiry_date: tokenData.expires_in
? Date.now() + (tokenData.expires_in * 1000)
: null
};
onSuccess(credentials);
return;
}
// Pending case (user hasn't authorized yet)
if (tokenData.error === 'authorization_pending') {
onProgress({
status: 'polling',
attempt: attempt + 1,
maxAttempts
});
continue;
}
// Slow down case
if (tokenData.error === 'slow_down') {
pollInterval = Math.min(pollInterval * 1.5, 10000);
console.log('[QwenOAuth] Server requested slow_down, interval now:', pollInterval);
continue;
}
// Access denied
if (tokenData.error === 'access_denied') {
onError('Access denied. Please try again.');
return;
}
// Other error
if (tokenData.error) {
onError(tokenData.error_description || tokenData.error);
return;
}
} catch (pollError) {
console.error('[QwenOAuth] Poll error:', pollError.message);
// Continue polling on network errors
}
}
// Timeout
onError('Authorization timed out. Please try again.');
} catch (error) {
console.error('[QwenOAuth] Device flow failed:', error);
onError(error.message);
}
}
/**
* Refresh an access token using a refresh token
*
* @param {string} refreshToken
* @returns {Promise<Object>} New credentials
*/
export async function refreshAccessToken(refreshToken) {
const body = objectToUrlEncoded({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: QWEN_OAUTH_CLIENT_ID
});
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token refresh failed: ${response.status} - ${errorText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error_description || data.error);
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token || refreshToken,
token_type: data.token_type || 'Bearer',
resource_url: data.resource_url || null,
expiry_date: data.expires_in ? Date.now() + (data.expires_in * 1000) : null
};
}
// ===== TOKEN PERSISTENCE (User-Isolated) =====
/**
* Get the token storage path for a specific user
* @param {string} userId
* @param {string} userDataPath - app.getPath('userData')
*/
export function getUserTokenPath(userId, userDataPath) {
return path.join(userDataPath, 'user_data', userId, 'qwen_tokens.json');
}
/**
* Save tokens for a specific user
*/
export async function saveUserTokens(userId, userDataPath, credentials) {
const tokenPath = getUserTokenPath(userId, userDataPath);
const dir = path.dirname(tokenPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tokenPath, JSON.stringify(credentials, null, 2));
console.log('[QwenOAuth] Tokens saved for user:', userId);
}
/**
* Load tokens for a specific user
*/
export function loadUserTokens(userId, userDataPath) {
const tokenPath = getUserTokenPath(userId, userDataPath);
try {
if (fs.existsSync(tokenPath)) {
return JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
}
} catch (e) {
console.error('[QwenOAuth] Failed to load user tokens:', e.message);
}
return null;
}
/**
* Clear tokens for a specific user
*/
export function clearUserTokens(userId, userDataPath) {
const tokenPath = getUserTokenPath(userId, userDataPath);
try {
if (fs.existsSync(tokenPath)) {
fs.unlinkSync(tokenPath);
console.log('[QwenOAuth] Tokens cleared for user:', userId);
}
} catch (e) {
console.warn('[QwenOAuth] Failed to clear tokens:', e.message);
}
}
// ===== LEGACY SUPPORT (Global tokens for backward compatibility) =====
const LEGACY_TOKEN_PATH = path.join(os.homedir(), '.qwen', 'oauth_creds.json');
/**
* Load tokens from legacy location (used when no user session)
*/
export function loadLegacyTokens() {
try {
if (fs.existsSync(LEGACY_TOKEN_PATH)) {
return JSON.parse(fs.readFileSync(LEGACY_TOKEN_PATH, 'utf8'));
}
} catch (e) {
console.error('[QwenOAuth] Failed to load legacy tokens:', e.message);
}
return null;
}
/**
* Save tokens to legacy location (for backward compatibility)
*/
export function saveLegacyTokens(credentials) {
const dir = path.dirname(LEGACY_TOKEN_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(LEGACY_TOKEN_PATH, JSON.stringify(credentials, null, 2));
}

View File

@@ -0,0 +1,474 @@
/**
* User Data Service for Goose Ultra
*
* Manages user authentication, session, and data isolation.
* Each user has their own isolated environment with separate:
* - Projects
* - Chat history
* - API keys (Qwen, Ollama)
* - Custom personas
* - Settings
*/
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { app } from 'electron';
// ===== USER DATA STRUCTURE =====
/**
* @typedef {Object} GooseUser
* @property {string} userId - UUID
* @property {string} displayName - User's chosen display name
* @property {string} secretCodeHash - SHA256 hash of the secret code
* @property {string} secretQuestionId - ID of the secret question used
* @property {number} createdAt - Timestamp
* @property {number} lastLoginAt - Timestamp
*/
/**
* @typedef {Object} UserSession
* @property {string} userId
* @property {string} displayName
* @property {number} loginAt
*/
// ===== FILE PATHS =====
const getSystemDir = () => path.join(app.getPath('userData'), 'system');
const getUsersFile = () => path.join(getSystemDir(), 'users.json');
const getSessionFile = () => path.join(getSystemDir(), 'current_session.json');
const getUserDataDir = () => path.join(app.getPath('userData'), 'user_data');
// ===== SECRET QUESTIONS =====
export const SECRET_QUESTIONS = [
{ id: 'mother_maiden', question: "What is your mother's maiden name?" },
{ id: 'first_pet', question: "What was your first pet's name?" },
{ id: 'favorite_teacher', question: "What was your favorite teacher's name?" },
{ id: 'birth_city', question: "In what city were you born?" },
{ id: 'first_car', question: "What was the make of your first car?" },
{ id: 'childhood_nickname', question: "What was your childhood nickname?" },
{ id: 'custom', question: "Custom question (user-defined)" }
];
// ===== SECRET CODE GENERATION =====
/**
* Generate a unique secret code for a new user
* Format: GU-XXXX-XXXX-XXXX (16 alphanumeric chars)
*
* @param {string} displayName
* @param {string} questionId
* @param {string} answer
* @returns {string} The secret code
*/
export function generateSecretCode(displayName, questionId, answer) {
const timestamp = Date.now().toString();
const salt = crypto.randomBytes(16).toString('hex');
const raw = `${displayName}|${questionId}|${answer}|${timestamp}|${salt}`;
// Create a hash and take 12 bytes
const hash = crypto.createHash('sha256').update(raw).digest();
const encoded = hash.slice(0, 12).toString('base64url').toUpperCase();
// Format as GU-XXXX-XXXX-XXXX
const formatted = `GU-${encoded.slice(0, 4)}-${encoded.slice(4, 8)}-${encoded.slice(8, 12)}`;
return formatted;
}
/**
* Hash a secret code for secure storage
* @param {string} secretCode
* @returns {string} SHA256 hash
*/
export function hashSecretCode(secretCode) {
// Normalize the code (remove dashes, uppercase)
const normalized = secretCode.replace(/-/g, '').toUpperCase();
return crypto.createHash('sha256').update(normalized).digest('hex');
}
// ===== USER MANAGEMENT =====
/**
* Ensure system directories exist
*/
function ensureSystemDirs() {
const systemDir = getSystemDir();
const userDataDir = getUserDataDir();
if (!fs.existsSync(systemDir)) {
fs.mkdirSync(systemDir, { recursive: true });
}
if (!fs.existsSync(userDataDir)) {
fs.mkdirSync(userDataDir, { recursive: true });
}
}
/**
* Load all registered users
* @returns {GooseUser[]}
*/
export function loadUsers() {
ensureSystemDirs();
try {
if (fs.existsSync(getUsersFile())) {
return JSON.parse(fs.readFileSync(getUsersFile(), 'utf8'));
}
} catch (e) {
console.error('[UserData] Failed to load users:', e.message);
}
return [];
}
/**
* Save users list
* @param {GooseUser[]} users
*/
function saveUsers(users) {
ensureSystemDirs();
fs.writeFileSync(getUsersFile(), JSON.stringify(users, null, 2));
}
/**
* Create a new user account
*
* @param {string} displayName
* @param {string} questionId
* @param {string} answer
* @returns {{ user: GooseUser, secretCode: string }}
*/
export function createUser(displayName, questionId, answer) {
ensureSystemDirs();
const userId = crypto.randomUUID();
const secretCode = generateSecretCode(displayName, questionId, answer);
const secretCodeHash = hashSecretCode(secretCode);
const now = Date.now();
const user = {
userId,
displayName,
secretCodeHash,
secretQuestionId: questionId,
createdAt: now,
lastLoginAt: now
};
// Add to users list
const users = loadUsers();
users.push(user);
saveUsers(users);
// Create user's data directory
const userDir = path.join(getUserDataDir(), userId);
fs.mkdirSync(userDir, { recursive: true });
// Create subdirectories
fs.mkdirSync(path.join(userDir, 'projects'), { recursive: true });
fs.mkdirSync(path.join(userDir, 'chats'), { recursive: true });
fs.mkdirSync(path.join(userDir, 'vault'), { recursive: true });
// Initialize settings
const defaultSettings = {
preferredFramework: null,
chatPersona: 'assistant',
theme: 'dark',
createdAt: now
};
fs.writeFileSync(
path.join(userDir, 'settings.json'),
JSON.stringify(defaultSettings, null, 2)
);
console.log('[UserData] Created new user:', userId, displayName);
return { user, secretCode };
}
/**
* Authenticate a user with their secret code
*
* @param {string} secretCode
* @returns {GooseUser | null}
*/
export function authenticateUser(secretCode) {
const hash = hashSecretCode(secretCode);
const users = loadUsers();
const user = users.find(u => u.secretCodeHash === hash);
if (user) {
// Update last login
user.lastLoginAt = Date.now();
saveUsers(users);
console.log('[UserData] User authenticated:', user.userId);
return user;
}
console.log('[UserData] Authentication failed: invalid secret code');
return null;
}
// ===== SESSION MANAGEMENT =====
/**
* Start a user session
* @param {GooseUser} user
*/
export function startSession(user) {
const session = {
userId: user.userId,
displayName: user.displayName,
loginAt: Date.now()
};
ensureSystemDirs();
fs.writeFileSync(getSessionFile(), JSON.stringify(session, null, 2));
console.log('[UserData] Session started for:', user.displayName);
return session;
}
/**
* Get the current active session
* @returns {UserSession | null}
*/
export function getCurrentSession() {
try {
if (fs.existsSync(getSessionFile())) {
return JSON.parse(fs.readFileSync(getSessionFile(), 'utf8'));
}
} catch (e) {
console.error('[UserData] Failed to load session:', e.message);
}
return null;
}
/**
* End the current session (logout)
*/
export function endSession() {
try {
if (fs.existsSync(getSessionFile())) {
fs.unlinkSync(getSessionFile());
console.log('[UserData] Session ended');
}
} catch (e) {
console.error('[UserData] Failed to end session:', e.message);
}
}
// ===== USER DATA PATHS =====
/**
* Get the data directory for a specific user
* @param {string} userId
*/
export function getUserDirectory(userId) {
return path.join(getUserDataDir(), userId);
}
/**
* Get the projects directory for a user
* @param {string} userId
*/
export function getUserProjectsDir(userId) {
return path.join(getUserDirectory(userId), 'projects');
}
/**
* Get the chats directory for a user
* @param {string} userId
*/
export function getUserChatsDir(userId) {
return path.join(getUserDirectory(userId), 'chats');
}
/**
* Get the vault directory for a user
* @param {string} userId
*/
export function getUserVaultDir(userId) {
return path.join(getUserDirectory(userId), 'vault');
}
/**
* Get user settings path
* @param {string} userId
*/
export function getUserSettingsPath(userId) {
return path.join(getUserDirectory(userId), 'settings.json');
}
// ===== DATA CLEANUP =====
/**
* Clean all data for a specific user
* This removes:
* - All projects
* - All chats
* - All saved credentials
* - Custom personas
* - Settings
*
* Note: The user account itself remains intact
*
* @param {string} userId
*/
export function cleanUserData(userId) {
const userDir = getUserDirectory(userId);
if (!fs.existsSync(userDir)) {
console.log('[UserData] No data to clean for user:', userId);
return;
}
// Remove all contents but keep the directory structure
const removeContents = (dir) => {
if (!fs.existsSync(dir)) return;
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
fs.rmSync(itemPath, { recursive: true, force: true });
} else {
fs.unlinkSync(itemPath);
}
}
};
// Clean each subdirectory
removeContents(path.join(userDir, 'projects'));
removeContents(path.join(userDir, 'chats'));
removeContents(path.join(userDir, 'vault'));
// Reset settings to default
const defaultSettings = {
preferredFramework: null,
chatPersona: 'assistant',
theme: 'dark',
createdAt: Date.now(),
cleanedAt: Date.now()
};
try {
fs.writeFileSync(
path.join(userDir, 'settings.json'),
JSON.stringify(defaultSettings, null, 2)
);
} catch (e) {
console.error('[UserData] Failed to reset settings:', e.message);
}
// Remove Qwen tokens
const tokenPath = path.join(userDir, 'qwen_tokens.json');
if (fs.existsSync(tokenPath)) {
fs.unlinkSync(tokenPath);
}
console.log('[UserData] Cleaned all data for user:', userId);
}
/**
* Delete a user account completely
* @param {string} userId
*/
export function deleteUser(userId) {
// Remove user data
const userDir = getUserDirectory(userId);
if (fs.existsSync(userDir)) {
fs.rmSync(userDir, { recursive: true, force: true });
}
// Remove from users list
let users = loadUsers();
users = users.filter(u => u.userId !== userId);
saveUsers(users);
console.log('[UserData] Deleted user:', userId);
}
// ===== MIGRATION =====
/**
* Migrate legacy global data to a user's isolated environment
* This is called when:
* 1. First user is created and old data exists
* 2. Explicitly requested by user
*
* @param {string} userId
* @param {string} legacyProjectsDir - Old global projects directory
*/
export function migrateGlobalDataToUser(userId, legacyProjectsDir) {
const userProjectsDir = getUserProjectsDir(userId);
if (!fs.existsSync(legacyProjectsDir)) {
console.log('[UserData] No legacy data to migrate');
return;
}
// Copy all projects
const projects = fs.readdirSync(legacyProjectsDir);
for (const project of projects) {
const src = path.join(legacyProjectsDir, project);
const dest = path.join(userProjectsDir, project);
if (fs.statSync(src).isDirectory()) {
fs.cpSync(src, dest, { recursive: true });
}
}
console.log('[UserData] Migrated', projects.length, 'projects to user:', userId);
}
// ===== STATISTICS =====
/**
* Get statistics about a user's data
* @param {string} userId
*/
export function getUserStats(userId) {
const userDir = getUserDirectory(userId);
const countItems = (dir) => {
try {
return fs.existsSync(dir) ? fs.readdirSync(dir).length : 0;
} catch {
return 0;
}
};
const getDirSize = (dir) => {
if (!fs.existsSync(dir)) return 0;
let size = 0;
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const itemPath = path.join(dir, item.name);
if (item.isDirectory()) {
size += getDirSize(itemPath);
} else {
size += fs.statSync(itemPath).size;
}
}
return size;
};
return {
projectCount: countItems(getUserProjectsDir(userId)),
chatCount: countItems(getUserChatsDir(userId)),
totalSizeBytes: getDirSize(userDir),
hasQwenTokens: fs.existsSync(path.join(userDir, 'qwen_tokens.json'))
};
}

View File

@@ -0,0 +1,511 @@
# Implementation Plan: Secret Key User System & Inline Qwen OAuth
## Overview
This plan outlines the implementation of:
1. **Secret Key User Authentication** - Users create accounts with a name + secret question, receive a unique key
2. **Isolated User Environments** - Each user has separate data (API keys, chats, sessions, projects)
3. **Inline Qwen OAuth** - Replace external CLI dependency with native device flow authentication
---
## Phase 1: User Identity & Secret Key System
### 1.1 Secret Code Generation
**Algorithm:**
```
SecretCode = Base64(SHA256(userName + secretQuestion + answer + timestamp + randomSalt))[:24]
```
Example output: `GU-AXBY12-CDWZ34-EFGH56`
**Security Properties:**
- One-way derivation (cannot reverse-engineer original answer)
- Time-salted to prevent duplicate codes
- 24-character code is memorable yet secure (144 bits of entropy)
### 1.2 User Data Model
```typescript
interface GooseUser {
userId: string; // UUID
displayName: string;
secretCodeHash: string; // SHA256 hash of the secret code (for verification)
createdAt: number;
lastLoginAt: number;
}
```
### 1.3 Files & Storage Structure
**Location:** `%AppData%/GooseUltra/` (Windows) or `~/.config/GooseUltra/` (Linux/Mac)
```
GooseUltra/
├── system/
│ ├── users.json # Array of GooseUser (stores hashes, not codes)
│ └── current_session.json # { userId, loginAt }
└── user_data/
└── {userId}/
├── settings.json # User-specific settings
├── qwen_tokens.json # User's Qwen OAuth credentials
├── ollama_key.enc # User's Ollama API key
├── projects/ # User's projects
├── chats/ # User's chat history
└── vault/ # User's credential vault
```
### 1.4 New Components
| Component | Location | Purpose |
|-----------|----------|---------|
| `LoginGate.tsx` | `src/components/` | Full-screen intro/login component |
| `UserOnboarding.tsx` | `src/components/` | Name + secret question wizard |
| `SecretCodeReveal.tsx` | `src/components/` | Shows code once with copy button |
| `UserContext.tsx` | `src/` | React context for current user |
### 1.5 Onboarding Flow
```
┌─────────────────────────────────────────┐
│ Welcome to Goose Ultra │
│ │
│ ○ I'm new here (Create Account) │
│ ○ I have a secret code (Login) │
└─────────────────────────────────────────┘
↓ "New User"
┌─────────────────────────────────────────┐
│ Step 1: What's your name? │
│ ┌─────────────────────────────────┐ │
│ │ [Your Display Name ] │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Step 2: Set Your Secret Question │
│ │
│ Pick a question (dropdown): │
│ • Mother's maiden name? │
│ • First pet's name? │
│ • Favorite teacher's name? │
│ • City you were born in? │
│ • Your custom question... │
│ │
│ Your answer: [______________] │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ 🎉 Your Secret Code is Ready! │
│ │
│ ┌──────────────────────────────────┐ │
│ │ GU-AXBY12-CDWZ34-EFGH56 │ │
│ └──────────────────────────────────┘ │
│ [📋 Copy to Clipboard] │
│ │
│ ⚠️ SAVE THIS CODE OFFLINE! │
│ This is the ONLY way to log in. │
│ We cannot recover it. │
│ │
│ [ ] I have saved my code securely │
│ │
│ [Continue to Goose Ultra →] │
└─────────────────────────────────────────┘
```
---
## Phase 2: User Data Isolation
### 2.1 Data Isolation Layer
**New Service:** `src/services/userDataService.ts`
```typescript
export class UserDataService {
private userId: string | null = null;
setCurrentUser(userId: string) { ... }
getUserDataPath(): string {
// Returns: userData/user_data/{userId}/
}
async loadUserSettings(): Promise<UserSettings> { ... }
async saveUserSettings(settings: UserSettings): Promise<void> { ... }
async loadQwenTokens(): Promise<QwenCredentials | null> { ... }
async saveQwenTokens(tokens: QwenCredentials): Promise<void> { ... }
async getProjectsPath(): string { ... }
async getChatsPath(): string { ... }
async cleanUserData(): Promise<void> {
// Wipes all user data (projects, chats, keys)
}
}
```
### 2.2 Logout & Clean Data
**Logout Flow:**
```
┌─────────────────────────────────────────┐
│ Logging Out... │
│ │
│ Would you like to clean your data? │
│ │
│ This will permanently delete: │
│ • All your projects │
│ • All chat history │
│ • Saved API keys │
│ • Custom personas │
│ │
│ Your account will remain intact. │
│ You can log in again with your code. │
│ │
│ [Keep Data & Logout] [Clean & Logout] │
└─────────────────────────────────────────┘
```
**"Clean Data" Explanation (to show users):**
> **What does "Clean Data" mean?**
>
> Cleaning your data removes all personal information from this device, including:
> - **Projects:** All HTML, CSS, and JavaScript you've created
> - **Chat History:** All conversations with the AI
> - **API Keys:** Any Qwen or Ollama credentials you've entered
> - **Personas:** Custom AI personalities you've configured
>
> **Why clean?**
> - You're using a shared or public computer
> - You want to free up disk space
> - You're troubleshooting issues
> - You want a fresh start
>
> **Note:** Your account code will still work. Cleaning only affects data on THIS device.
---
## Phase 3: Inline Qwen OAuth (No External CLI)
### 3.1 Current vs. New Architecture
**Current Flow (Requires External CLI):**
```
User clicks "Auth" → Electron opens external Qwen CLI → CLI does OAuth → Writes ~/.qwen/oauth_creds.json → Goose reads it
```
**New Flow (Fully Inline):**
```
User clicks "Auth" → Electron starts Device Flow → Opens browser for authorization → Polls for token → Saves per-user
```
### 3.2 New Electron Module: `qwen-oauth.js`
**Based on:** `qwen-code-reference/packages/core/src/qwen/qwenOAuth2.ts`
```javascript
// electron/qwen-oauth.js
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = 'https://chat.qwen.ai/api/v1/oauth2/device/code';
const QWEN_OAUTH_TOKEN_ENDPOINT = 'https://chat.qwen.ai/api/v1/oauth2/token';
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
// PKCE Helpers
function generateCodeVerifier() { ... }
function generateCodeChallenge(verifier) { ... }
// Main OAuth Flow
export async function startDeviceFlow(onProgress, onSuccess, onError) {
// 1. Generate PKCE pair
const { code_verifier, code_challenge } = generatePKCEPair();
// 2. Request device code from Qwen
const deviceAuthResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: QWEN_OAUTH_CLIENT_ID,
scope: QWEN_OAUTH_SCOPE,
code_challenge,
code_challenge_method: 'S256'
})
});
const { device_code, user_code, verification_uri_complete, expires_in } = await deviceAuthResponse.json();
// 3. Notify UI with authorization URL
onProgress({
status: 'awaiting_auth',
url: verification_uri_complete,
userCode: user_code,
expiresIn: expires_in
});
// 4. Open browser automatically
shell.openExternal(verification_uri_complete);
// 5. Poll for token
const pollInterval = 2000;
const maxAttempts = Math.ceil(expires_in / (pollInterval / 1000));
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await sleep(pollInterval);
const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: QWEN_OAUTH_GRANT_TYPE,
client_id: QWEN_OAUTH_CLIENT_ID,
device_code,
code_verifier
})
});
const tokenData = await tokenResponse.json();
if (tokenData.access_token) {
// SUCCESS!
const credentials = {
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token,
token_type: tokenData.token_type,
resource_url: tokenData.resource_url,
expiry_date: Date.now() + (tokenData.expires_in * 1000)
};
onSuccess(credentials);
return;
}
if (tokenData.error === 'authorization_pending') {
onProgress({ status: 'polling', attempt, maxAttempts });
continue;
}
if (tokenData.error === 'slow_down') {
pollInterval = Math.min(pollInterval * 1.5, 10000);
continue;
}
// Other error
onError(tokenData.error_description || tokenData.error);
return;
}
onError('Authorization timed out');
}
export async function refreshAccessToken(refreshToken) { ... }
```
### 3.3 IPC Bridge Updates
**New handlers in `main.js`:**
```javascript
import * as qwenOAuth from './qwen-oauth.js';
// Start Device Authorization Flow
ipcMain.on('qwen-auth-start', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
await qwenOAuth.startDeviceFlow(
(progress) => window.webContents.send('qwen-auth-progress', progress),
(credentials) => {
// Save to user-specific location
const userId = getCurrentUserId(); // From session
userDataService.saveQwenTokens(userId, credentials);
window.webContents.send('qwen-auth-success', credentials);
},
(error) => window.webContents.send('qwen-auth-error', error)
);
});
// Cancel ongoing auth
ipcMain.on('qwen-auth-cancel', () => {
qwenOAuth.cancelAuth();
});
```
### 3.4 Preload Updates
```javascript
// preload.js - add to existing
qwenAuth: {
start: () => ipcRenderer.send('qwen-auth-start'),
cancel: () => ipcRenderer.send('qwen-auth-cancel'),
onProgress: (cb) => ipcRenderer.on('qwen-auth-progress', (_, data) => cb(data)),
onSuccess: (cb) => ipcRenderer.on('qwen-auth-success', (_, creds) => cb(creds)),
onError: (cb) => ipcRenderer.on('qwen-auth-error', (_, err) => cb(err)),
}
```
### 3.5 UI Component: Inline Auth Dialog
```tsx
// src/components/QwenAuthDialog.tsx
export const QwenAuthDialog = ({ onComplete }: { onComplete: () => void }) => {
const [status, setStatus] = useState<'idle' | 'awaiting' | 'polling' | 'success' | 'error'>('idle');
const [authUrl, setAuthUrl] = useState('');
const [userCode, setUserCode] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (!window.electron?.qwenAuth) return;
window.electron.qwenAuth.onProgress((data) => {
if (data.status === 'awaiting_auth') {
setStatus('awaiting');
setAuthUrl(data.url);
setUserCode(data.userCode);
} else if (data.status === 'polling') {
setStatus('polling');
}
});
window.electron.qwenAuth.onSuccess(() => {
setStatus('success');
setTimeout(onComplete, 1500);
});
window.electron.qwenAuth.onError((err) => {
setStatus('error');
setError(err);
});
}, []);
const startAuth = () => {
setStatus('awaiting');
window.electron?.qwenAuth?.start();
};
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<div className="bg-zinc-900 rounded-2xl p-8 max-w-md w-full border border-white/10">
{status === 'idle' && (
<>
<h2 className="text-2xl font-bold mb-4">Connect to Qwen</h2>
<p className="text-zinc-400 mb-6">
Authenticate with your Qwen account to access AI models.
</p>
<button onClick={startAuth} className="w-full py-3 bg-primary text-black font-bold rounded-xl">
Sign in with Qwen
</button>
</>
)}
{status === 'awaiting' && (
<>
<h2 className="text-2xl font-bold mb-4">Complete in Browser</h2>
<p className="text-zinc-400 mb-4">
A browser window should have opened. Enter this code:
</p>
<div className="bg-black p-4 rounded-xl text-center mb-4">
<span className="font-mono text-3xl text-primary">{userCode}</span>
</div>
<a href={authUrl} target="_blank" className="text-primary underline text-sm">
Click here if browser didn't open
</a>
</>
)}
{status === 'polling' && (
<>
<h2 className="text-2xl font-bold mb-4">Waiting for Authorization...</h2>
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
</>
)}
{status === 'success' && (
<>
<h2 className="text-2xl font-bold text-primary mb-4">✓ Connected!</h2>
</>
)}
{status === 'error' && (
<>
<h2 className="text-2xl font-bold text-red-500 mb-4">Authentication Failed</h2>
<p className="text-zinc-400 mb-6">{error}</p>
<button onClick={startAuth} className="w-full py-3 bg-zinc-800 text-white font-bold rounded-xl">
Try Again
</button>
</>
)}
</div>
</div>
);
};
```
---
## Phase 4: Implementation Order
### Step 1: Foundation (Electron Main)
1. Create `userDataService.js` in `electron/`
2. Create `qwen-oauth.js` in `electron/`
3. Update `main.js` with new IPC handlers
4. Update `preload.js` with new bridges
### Step 2: User System (React)
1. Create `UserContext.tsx`
2. Create `LoginGate.tsx`
3. Create `UserOnboarding.tsx`
4. Create `SecretCodeReveal.tsx`
5. Wrap `App.tsx` with `LoginGate`
### Step 3: Data Migration
1. Migrate existing global data to first user
2. Update all file paths in services to use `userDataService`
### Step 4: Qwen OAuth UI
1. Create `QwenAuthDialog.tsx`
2. Update `AISettingsModal` to use inline auth
3. Remove references to external CLI
### Step 5: Logout & Cleanup
1. Add logout button to sidebar
2. Create cleanup dialog with explanation
3. Implement `cleanUserData()` function
---
## Critical Files to Modify
| File | Changes |
|------|---------|
| `electron/main.js` | Add user session management, new IPC handlers |
| `electron/preload.js` | Expose user and auth bridges |
| `electron/qwen-api.js` | Load tokens from user-specific path |
| `src/App.tsx` | Wrap with LoginGate and UserContext |
| `src/orchestrator.ts` | Make project loading user-aware |
| `src/services/automationService.ts` | Update file paths |
| `src/components/LayoutComponents.tsx` | Add logout button, update auth UI |
---
## Security Considerations
1. **Secret Code Storage**: Only SHA256 hash is stored; actual code never persisted
2. **Credential Isolation**: Each user's Qwen/Ollama tokens are in separate directories
3. **Clean Data**: Complete wipe of user-specific folder
4. **No Recovery**: By design, secret codes cannot be recovered (offline storage is essential)
---
## Estimated Effort
| Phase | Effort |
|-------|--------|
| Phase 1: User Identity | 4-6 hours |
| Phase 2: Data Isolation | 3-4 hours |
| Phase 3: Inline OAuth | 4-5 hours |
| Phase 4: Integration | 2-3 hours |
| **Total** | **13-18 hours** |

View File

@@ -5,6 +5,7 @@ import { TabNav, StartView, PlanView, PreviewView, EditorView, DiscoverView, Com
import { ViControlView } from './components/ViControlView'; import { ViControlView } from './components/ViControlView';
import { TabId, OrchestratorState, GlobalMode } from './types'; import { TabId, OrchestratorState, GlobalMode } from './types';
import { ErrorBoundary } from './ErrorBoundary'; import { ErrorBoundary } from './ErrorBoundary';
import { LoginGate } from './components/UserAuth';
const MainLayout = () => { const MainLayout = () => {
const { state } = useOrchestrator(); const { state } = useOrchestrator();
@@ -66,10 +67,13 @@ const MainLayout = () => {
export default function App() { export default function App() {
return ( return (
<OrchestratorProvider> <LoginGate>
<ErrorBoundary> <OrchestratorProvider>
<MainLayout /> <ErrorBoundary>
</ErrorBoundary> <MainLayout />
</OrchestratorProvider> </ErrorBoundary>
</OrchestratorProvider>
</LoginGate>
); );
} }

View File

@@ -4,6 +4,8 @@ import { Icons } from '../constants';
import { GlobalMode, OrchestratorState, TabId } from '../types'; import { GlobalMode, OrchestratorState, TabId } from '../types';
import { applyPlanToExistingHtml, deleteProjectFromDisk, generateMockFiles, compilePlanToCode, MODERN_TEMPLATE_PROMPT, FRAMEWORK_TEMPLATE_PROMPT, loadProjectFilesFromDisk, writeLastActiveProjectId, ensureProjectOnDisk, loadProjectMemories, deleteMemory, updateMemory, MemoryRecord, formatMemoriesForPrompt, retrieveRelevantMemories } from '../services/automationService'; import { applyPlanToExistingHtml, deleteProjectFromDisk, generateMockFiles, compilePlanToCode, MODERN_TEMPLATE_PROMPT, FRAMEWORK_TEMPLATE_PROMPT, loadProjectFilesFromDisk, writeLastActiveProjectId, ensureProjectOnDisk, loadProjectMemories, deleteMemory, updateMemory, MemoryRecord, formatMemoriesForPrompt, retrieveRelevantMemories } from '../services/automationService';
import { classifyIntent, enhancePromptWithContext, loadProjectManifest, initializeProjectContext, IntentAnalysis, ProjectManifest, CLIE_VERSION } from '../services/ContextEngine'; import { classifyIntent, enhancePromptWithContext, loadProjectManifest, initializeProjectContext, IntentAnalysis, ProjectManifest, CLIE_VERSION } from '../services/ContextEngine';
import { useUser, LogoutDialog } from './UserAuth';
import QwenAuthDialog from './QwenAuthDialog';
// --- Shared Constants --- // --- Shared Constants ---
const FRAMEWORK_KEYWORDS = ['react', 'vue', 'svelte', 'bootstrap', 'jquery', 'three.js', 'p5.js', 'angular', 'alpine']; const FRAMEWORK_KEYWORDS = ['react', 'vue', 'svelte', 'bootstrap', 'jquery', 'three.js', 'p5.js', 'angular', 'alpine'];
@@ -463,10 +465,44 @@ const StatusDot = ({ active, label }: { active: boolean, label: string }) => (
export const Sidebar = () => { export const Sidebar = () => {
const { state, dispatch } = useOrchestrator(); const { state, dispatch } = useOrchestrator();
const { session, logout } = useUser();
if (!state.sidebarOpen) return null; if (!state.sidebarOpen) return null;
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [renamingId, setRenamingId] = useState<string | null>(null); const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState('');
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [showQwenAuthDialog, setShowQwenAuthDialog] = useState(false);
const [qwenAuthStatus, setQwenAuthStatus] = useState<{ isAuthenticated: boolean; isValid: boolean }>({ isAuthenticated: false, isValid: false });
const [userStats, setUserStats] = useState({ projectCount: 0, chatCount: 0, totalSizeBytes: 0 });
// Check Qwen auth status on mount
useEffect(() => {
const checkQwenAuth = async () => {
const electron = (window as any).electron;
if (electron?.qwenAuth?.getStatus) {
const status = await electron.qwenAuth.getStatus();
setQwenAuthStatus(status);
}
};
checkQwenAuth();
}, []);
// Load user stats when logout dialog opens
useEffect(() => {
if (showLogoutDialog && session?.userId) {
const electron = (window as any).electron;
if (electron?.user?.getStats) {
electron.user.getStats(session.userId).then(setUserStats);
}
}
}, [showLogoutDialog, session?.userId]);
const handleLogout = async (cleanData: boolean) => {
setShowLogoutDialog(false);
await logout(cleanData);
// Force page reload to show login screen
window.location.reload();
};
const handleSelectProject = async (projectId: string) => { const handleSelectProject = async (projectId: string) => {
dispatch({ type: 'SELECT_PROJECT', projectId }); dispatch({ type: 'SELECT_PROJECT', projectId });
@@ -716,24 +752,27 @@ export const Sidebar = () => {
{/* Qwen OAuth Status */} {/* Qwen OAuth Status */}
<div <div
className="flex items-center gap-2 text-xs cursor-pointer transition-colors p-2 hover:bg-white/5 rounded-lg mx-2 group" className={`flex items-center gap-2 text-xs cursor-pointer transition-colors p-2 hover:bg-white/5 rounded-lg mx-2 group ${qwenAuthStatus.isAuthenticated ? '' : 'opacity-70'}`}
onClick={() => { onClick={() => setShowQwenAuthDialog(true)}
// Trigger Qwen OAuth flow title={qwenAuthStatus.isAuthenticated ? "Qwen Cloud connected • Click to refresh" : "Click to authenticate with Qwen"}
const electron = (window as any).electron;
if (electron?.openQwenAuth) {
electron.openQwenAuth();
}
}}
title="Click to authenticate with Qwen"
> >
<div className="w-6 h-6 rounded-lg bg-emerald-500/20 flex items-center justify-center border border-emerald-500/30"> <div className={`w-6 h-6 rounded-lg flex items-center justify-center border ${qwenAuthStatus.isAuthenticated
<span className="text-emerald-400 font-bold text-xs">Q</span> ? 'bg-emerald-500/20 border-emerald-500/30'
: 'bg-zinc-800 border-white/10'
}`}>
<span className={`font-bold text-xs ${qwenAuthStatus.isAuthenticated ? 'text-emerald-400' : 'text-zinc-500'}`}>Q</span>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-zinc-300 text-xs font-medium">Qwen Cloud</div> <div className="text-zinc-300 text-xs font-medium">Qwen Cloud</div>
<div className="text-[9px] text-emerald-500">Connected Free Tier</div> <div className={`text-[9px] ${qwenAuthStatus.isAuthenticated ? 'text-emerald-500' : 'text-zinc-600'}`}>
{qwenAuthStatus.isAuthenticated ? (qwenAuthStatus.isValid ? 'Connected • Ready' : 'Token expired') : 'Click to authenticate'}
</div>
</div> </div>
<Icons.CheckCircle className="w-3.5 h-3.5 text-emerald-500" /> {qwenAuthStatus.isAuthenticated ? (
<Icons.CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
) : (
<Icons.Plus className="w-3.5 h-3.5 text-zinc-500 group-hover:text-white transition-colors" />
)}
</div> </div>
{/* Ollama Cloud Status */} {/* Ollama Cloud Status */}
@@ -789,8 +828,54 @@ export const Sidebar = () => {
</div> </div>
)} )}
</div> </div>
{/* User Session Section */}
{session && (
<div className="mt-auto pt-4 border-t border-white/5">
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mb-3 px-2 flex items-center gap-2">
<Icons.User className="w-3 h-3" /> Account
</div>
<div className="px-2 mb-2">
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
<div className="text-xs text-white font-medium truncate">{session.displayName}</div>
<div className="text-[9px] text-zinc-500 mt-1">
Logged in since {new Date(session.loginAt).toLocaleDateString()}
</div>
</div>
</div>
<div
onClick={() => setShowLogoutDialog(true)}
className="flex items-center gap-2 text-xs cursor-pointer transition-colors p-2 hover:bg-rose-500/10 rounded-lg mx-2 group text-zinc-400 hover:text-rose-400"
title="Logout"
>
<Icons.X className="w-3.5 h-3.5" />
<span>Logout</span>
</div>
</div>
)}
</div> </div>
</div >
{/* Dialogs */}
<LogoutDialog
isOpen={showLogoutDialog}
onClose={() => setShowLogoutDialog(false)}
onLogout={handleLogout}
userName={session?.displayName || 'User'}
stats={userStats}
/>
<QwenAuthDialog
isOpen={showQwenAuthDialog}
onClose={() => setShowQwenAuthDialog(false)}
onSuccess={async () => {
// Refresh auth status after successful auth
const electron = (window as any).electron;
if (electron?.qwenAuth?.getStatus) {
const status = await electron.qwenAuth.getStatus();
setQwenAuthStatus(status);
}
}}
/>
</div>
); );
}; };
@@ -2422,7 +2507,7 @@ Format: { "ideas": [{ "title": "Short Title", "subtitle": "One line", "tag": "To
}, 45000); }, 45000);
let systemPrompt = ''; let systemPrompt = '';
const isModificationMode = state.state === OrchestratorState.PreviewReady || state.state === OrchestratorState.Editing; const isModificationMode = (state.state === OrchestratorState.PreviewReady || state.state === OrchestratorState.Editing);
let requestKind: 'chat' | 'plan' | 'code' = (isChatMode || isBrainstormMode) ? 'chat' : 'plan'; let requestKind: 'chat' | 'plan' | 'code' = (isChatMode || isBrainstormMode) ? 'chat' : 'plan';
// SMART ROUTING: REMOVED CONCIERGE (F4: Plan First Enforcement) // SMART ROUTING: REMOVED CONCIERGE (F4: Plan First Enforcement)
@@ -2430,6 +2515,7 @@ Format: { "ideas": [{ "title": "Short Title", "subtitle": "One line", "tag": "To
if (isChatMode) { if (isChatMode) {
const sysP = personaSystem(state, state.chatPersona, state.customChatPersonaPrompt); const sysP = personaSystem(state, state.chatPersona, state.customChatPersonaPrompt);
systemPrompt = `[SYSTEM INSTRUCTION]: ${sysP}\n\n[CONTEXT]: This is CHAT mode (not building). Do not generate code unless explicitly asked. If user asks for a change to an existing project, propose a plan starting with '[PLAN]' and wait for approval. Use PLAIN PROSE for conversation, no markdown code blocks for speech.\n\n[IMAGE GENERATION]: You CAN generate images! If the user asks for an image, painting, illustration, or visual content, acknowledge that you're generating it. The system will handle the actual generation. Say something like "I'm creating that image for you now, please wait a moment...".`; systemPrompt = `[SYSTEM INSTRUCTION]: ${sysP}\n\n[CONTEXT]: This is CHAT mode (not building). Do not generate code unless explicitly asked. If user asks for a change to an existing project, propose a plan starting with '[PLAN]' and wait for approval. Use PLAIN PROSE for conversation, no markdown code blocks for speech.\n\n[IMAGE GENERATION]: You CAN generate images! If the user asks for an image, painting, illustration, or visual content, acknowledge that you're generating it. The system will handle the actual generation. Say something like "I'm creating that image for you now, please wait a moment...".`;
} else if (isBrainstormMode) { } else if (isBrainstormMode) {
const lower = userPrompt.toLowerCase(); const lower = userPrompt.toLowerCase();
const wantsPlan = lower.includes('plan') || lower.includes('formalize') || lower.includes('blueprint'); const wantsPlan = lower.includes('plan') || lower.includes('formalize') || lower.includes('blueprint');
@@ -2470,6 +2556,7 @@ Format: { "ideas": [{ "title": "Short Title", "subtitle": "One line", "tag": "To
(window as any)._redesignApprovedSessions[state.activeProject.id] = true; (window as any)._redesignApprovedSessions[state.activeProject.id] = true;
} }
if (isQaFailureArtifact) { if (isQaFailureArtifact) {
// --- REPAIR MODE (F3: Retention & Match) --- // --- REPAIR MODE (F3: Retention & Match) ---
// "Broken frontend is treated as a REPAIR task" // "Broken frontend is treated as a REPAIR task"

View File

@@ -0,0 +1,481 @@
/**
* Qwen OAuth Dialog for Goose Ultra
*
* Provides an inline OAuth flow using the Device Authorization Grant.
* Eliminates the need for external Qwen CLI.
*/
import React, { useState, useEffect } from 'react';
// ===== TYPES =====
interface AuthProgress {
status: 'awaiting_auth' | 'polling';
url?: string;
userCode?: string;
expiresIn?: number;
attempt?: number;
maxAttempts?: number;
}
type AuthStatus = 'idle' | 'starting' | 'awaiting' | 'polling' | 'success' | 'error';
interface QwenAuthDialogProps {
isOpen: boolean;
onClose: () => void;
onSuccess?: () => void;
}
// ===== ICONS =====
const Icons = {
Globe: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" x2="22" y1="12" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
),
Check: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
),
X: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" x2="6" y1="6" y2="18"></line>
<line x1="6" x2="18" y1="6" y2="18"></line>
</svg>
),
Loader: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="animate-spin">
<line x1="12" x2="12" y1="2" y2="6"></line>
<line x1="12" x2="12" y1="18" y2="22"></line>
<line x1="4.93" x2="7.76" y1="4.93" y2="7.76"></line>
<line x1="16.24" x2="19.07" y1="16.24" y2="19.07"></line>
<line x1="2" x2="6" y1="12" y2="12"></line>
<line x1="18" x2="22" y1="12" y2="12"></line>
<line x1="4.93" x2="7.76" y1="19.07" y2="16.24"></line>
<line x1="16.24" x2="19.07" y1="7.76" y2="4.93"></line>
</svg>
),
Copy: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
),
ExternalLink: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" x2="21" y1="14" y2="3"></line>
</svg>
)
};
// ===== STYLES =====
const styles = {
overlay: {
position: 'fixed' as const,
inset: 0,
background: 'rgba(0, 0, 0, 0.85)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000
},
dialog: {
background: 'linear-gradient(180deg, rgba(28, 28, 35, 0.98) 0%, rgba(18, 18, 22, 0.98) 100%)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '20px',
padding: '32px',
maxWidth: '440px',
width: '100%',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.8)',
position: 'relative' as const
},
closeButton: {
position: 'absolute' as const,
top: '16px',
right: '16px',
background: 'rgba(255, 255, 255, 0.05)',
border: 'none',
borderRadius: '8px',
padding: '8px',
color: '#71717a',
cursor: 'pointer',
transition: 'all 0.2s'
},
title: {
fontSize: '22px',
fontWeight: 700,
color: '#fff',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '12px'
},
subtitle: {
fontSize: '14px',
color: '#71717a',
marginBottom: '24px'
},
codeBox: {
background: 'rgba(0, 0, 0, 0.5)',
border: '2px solid rgba(34, 211, 238, 0.4)',
borderRadius: '16px',
padding: '24px',
textAlign: 'center' as const,
marginBottom: '20px'
},
userCode: {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '36px',
fontWeight: 700,
color: '#22d3ee',
letterSpacing: '4px',
marginBottom: '12px'
},
copyButton: {
background: 'rgba(255, 255, 255, 0.1)',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '8px',
padding: '8px 16px',
color: '#fff',
fontSize: '13px',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
transition: 'all 0.2s'
},
linkButton: {
width: '100%',
padding: '14px',
background: 'linear-gradient(135deg, #22d3ee 0%, #3b82f6 100%)',
border: 'none',
borderRadius: '12px',
color: '#000',
fontSize: '15px',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
marginBottom: '16px',
transition: 'all 0.2s'
},
secondaryButton: {
width: '100%',
padding: '12px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '10px',
color: '#a1a1aa',
fontSize: '14px',
cursor: 'pointer',
transition: 'all 0.2s'
},
spinner: {
width: '48px',
height: '48px',
margin: '0 auto 20px',
border: '3px solid rgba(34, 211, 238, 0.2)',
borderTopColor: '#22d3ee',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
},
pollingInfo: {
background: 'rgba(34, 211, 238, 0.1)',
border: '1px solid rgba(34, 211, 238, 0.2)',
borderRadius: '10px',
padding: '12px 16px',
fontSize: '13px',
color: '#22d3ee',
textAlign: 'center' as const,
marginTop: '16px'
},
successIcon: {
width: '64px',
height: '64px',
margin: '0 auto 16px',
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff'
},
errorBox: {
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '12px',
padding: '16px',
color: '#ef4444',
fontSize: '14px',
marginBottom: '20px',
textAlign: 'center' as const
}
};
// Add keyframes for spinner animation
const spinnerKeyframes = `
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
// ===== COMPONENT =====
export const QwenAuthDialog: React.FC<QwenAuthDialogProps> = ({ isOpen, onClose, onSuccess }) => {
const [status, setStatus] = useState<AuthStatus>('idle');
const [authUrl, setAuthUrl] = useState('');
const [userCode, setUserCode] = useState('');
const [error, setError] = useState('');
const [pollAttempt, setPollAttempt] = useState(0);
const [maxAttempts, setMaxAttempts] = useState(0);
const [copied, setCopied] = useState(false);
// Setup listeners on mount
useEffect(() => {
const electron = (window as any).electron;
if (!electron?.qwenAuth) return;
electron.qwenAuth.onProgress((data: AuthProgress) => {
if (data.status === 'awaiting_auth') {
setStatus('awaiting');
setAuthUrl(data.url || '');
setUserCode(data.userCode || '');
if (data.expiresIn) {
setMaxAttempts(Math.ceil(data.expiresIn / 2)); // 2 second intervals
}
} else if (data.status === 'polling') {
setStatus('polling');
setPollAttempt(data.attempt || 0);
setMaxAttempts(data.maxAttempts || 0);
}
});
electron.qwenAuth.onSuccess(() => {
setStatus('success');
setTimeout(() => {
onSuccess?.();
onClose();
}, 1500);
});
electron.qwenAuth.onError((err: string) => {
setStatus('error');
setError(err);
});
return () => {
electron.qwenAuth.removeListeners();
};
}, [onClose, onSuccess]);
// Reset state when dialog opens
useEffect(() => {
if (isOpen) {
setStatus('idle');
setAuthUrl('');
setUserCode('');
setError('');
setPollAttempt(0);
setMaxAttempts(0);
}
}, [isOpen]);
const startAuth = () => {
const electron = (window as any).electron;
if (!electron?.qwenAuth) return;
setStatus('starting');
setError('');
electron.qwenAuth.start();
};
const cancelAuth = () => {
const electron = (window as any).electron;
if (electron?.qwenAuth) {
electron.qwenAuth.cancel();
}
onClose();
};
const copyCode = async () => {
try {
await navigator.clipboard.writeText(userCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error('Failed to copy:', e);
}
};
const openAuthUrl = () => {
if (authUrl) {
window.open(authUrl, '_blank');
}
};
if (!isOpen) return null;
return (
<>
<style>{spinnerKeyframes}</style>
<div style={styles.overlay}>
<div style={styles.dialog}>
<button style={styles.closeButton} onClick={cancelAuth}>
<Icons.X />
</button>
{/* IDLE STATE - Start Auth */}
{status === 'idle' && (
<>
<div style={styles.title}>
<span style={{ fontSize: '28px' }}>🔐</span>
Connect to Qwen
</div>
<p style={styles.subtitle}>
Authenticate with your Qwen account to access powerful AI models.
No external tools required!
</p>
<button style={styles.linkButton} onClick={startAuth}>
<Icons.Globe />
Sign in with Qwen
</button>
<button style={styles.secondaryButton} onClick={onClose}>
Cancel
</button>
</>
)}
{/* STARTING STATE */}
{status === 'starting' && (
<>
<div style={styles.title}>Connecting...</div>
<div style={{ textAlign: 'center', padding: '32px 0' }}>
<div style={styles.spinner} />
<p style={{ color: '#71717a' }}>Initializing device authorization...</p>
</div>
</>
)}
{/* AWAITING AUTHORIZATION */}
{status === 'awaiting' && (
<>
<div style={styles.title}>Complete in Browser</div>
<p style={styles.subtitle}>
A browser window should have opened. Enter this code when prompted:
</p>
<div style={styles.codeBox}>
<div style={styles.userCode}>{userCode}</div>
<button
style={{
...styles.copyButton,
background: copied ? 'rgba(34, 197, 94, 0.2)' : styles.copyButton.background,
borderColor: copied ? '#22c55e' : 'rgba(255, 255, 255, 0.2)'
}}
onClick={copyCode}
>
{copied ? <Icons.Check /> : <Icons.Copy />}
{copied ? 'Copied!' : 'Copy Code'}
</button>
</div>
<button style={styles.linkButton} onClick={openAuthUrl}>
<Icons.ExternalLink />
Open Authorization Page
</button>
<div style={styles.pollingInfo}>
Waiting for authorization...
</div>
<button
style={{ ...styles.secondaryButton, marginTop: '16px' }}
onClick={cancelAuth}
>
Cancel
</button>
</>
)}
{/* POLLING STATE */}
{status === 'polling' && (
<>
<div style={styles.title}>Waiting for Authorization...</div>
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<div style={styles.spinner} />
<p style={{ color: '#71717a', marginBottom: '8px' }}>
Complete the sign-in in your browser
</p>
<p style={{ color: '#52525b', fontSize: '13px' }}>
Check {pollAttempt} / {maxAttempts}
</p>
</div>
{userCode && (
<div style={styles.codeBox}>
<div style={{ ...styles.userCode, fontSize: '28px' }}>{userCode}</div>
</div>
)}
<button style={styles.secondaryButton} onClick={cancelAuth}>
Cancel
</button>
</>
)}
{/* SUCCESS STATE */}
{status === 'success' && (
<>
<div style={styles.successIcon}>
<Icons.Check />
</div>
<div style={{ ...styles.title, justifyContent: 'center' }}>
Connected!
</div>
<p style={{ ...styles.subtitle, textAlign: 'center' }}>
You're now authenticated with Qwen.
</p>
</>
)}
{/* ERROR STATE */}
{status === 'error' && (
<>
<div style={styles.title}>
<span style={{ fontSize: '28px' }}>❌</span>
Authentication Failed
</div>
<div style={styles.errorBox}>
{error || 'An unknown error occurred'}
</div>
<button style={styles.linkButton} onClick={startAuth}>
Try Again
</button>
<button style={styles.secondaryButton} onClick={onClose}>
Cancel
</button>
</>
)}
</div>
</div>
</>
);
};
export default QwenAuthDialog;

View File

@@ -0,0 +1,800 @@
/**
* User Authentication Components for Goose Ultra
*
* Components:
* - LoginGate: Full-screen wrapper that enforces authentication
* - UserOnboarding: Name + secret question wizard
* - SecretCodeReveal: Shows code once with copy button
* - LogoutDialog: Confirmation with clean data option
*/
import React, { useState, useEffect, createContext, useContext, ReactNode } from 'react';
// ===== TYPES =====
interface GooseUser {
userId: string;
displayName: string;
secretQuestionId: string;
createdAt: number;
lastLoginAt: number;
}
interface UserSession {
userId: string;
displayName: string;
loginAt: number;
}
interface SecretQuestion {
id: string;
question: string;
}
interface UserContextType {
session: UserSession | null;
user: GooseUser | null;
isLoading: boolean;
logout: (cleanData?: boolean) => Promise<void>;
refreshSession: () => Promise<void>;
}
// ===== CONTEXT =====
const UserContext = createContext<UserContextType>({
session: null,
user: null,
isLoading: true,
logout: async () => { },
refreshSession: async () => { }
});
export const useUser = () => useContext(UserContext);
// ===== ICONS =====
const Icons = {
User: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
),
Key: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"></path>
</svg>
),
Check: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
),
Copy: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
</svg>
),
Alert: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>
),
Logout: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" x2="9" y1="12" y2="12"></line>
</svg>
),
Trash: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
</svg>
),
ArrowRight: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14"></path>
<path d="m12 5 7 7-7 7"></path>
</svg>
)
};
// ===== STYLES =====
const styles = {
container: {
position: 'fixed' as const,
inset: 0,
background: 'linear-gradient(135deg, #030304 0%, #0a0a0f 50%, #030304 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
},
card: {
background: 'rgba(20, 20, 25, 0.95)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '24px',
padding: '48px',
maxWidth: '480px',
width: '100%',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.8)',
backdropFilter: 'blur(20px)'
},
logo: {
width: '80px',
height: '80px',
margin: '0 auto 24px',
display: 'block',
borderRadius: '20px',
background: 'linear-gradient(135deg, #22d3ee 0%, #3b82f6 100%)',
padding: '16px'
},
title: {
fontSize: '28px',
fontWeight: 700,
textAlign: 'center' as const,
marginBottom: '8px',
background: 'linear-gradient(135deg, #fff 0%, #a1a1aa 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
},
subtitle: {
fontSize: '15px',
color: '#71717a',
textAlign: 'center' as const,
marginBottom: '32px'
},
input: {
width: '100%',
padding: '14px 16px',
background: 'rgba(0, 0, 0, 0.4)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '12px',
color: '#fff',
fontSize: '16px',
outline: 'none',
transition: 'all 0.2s',
marginBottom: '16px',
boxSizing: 'border-box' as const
},
select: {
width: '100%',
padding: '14px 16px',
background: 'rgba(0, 0, 0, 0.4)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '12px',
color: '#fff',
fontSize: '16px',
outline: 'none',
marginBottom: '16px',
cursor: 'pointer',
boxSizing: 'border-box' as const
},
button: {
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #22d3ee 0%, #3b82f6 100%)',
border: 'none',
borderRadius: '12px',
color: '#000',
fontSize: '16px',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'all 0.2s'
},
buttonSecondary: {
width: '100%',
padding: '16px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '12px',
color: '#fff',
fontSize: '16px',
fontWeight: 500,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'all 0.2s',
marginTop: '12px'
},
codeBox: {
background: 'rgba(0, 0, 0, 0.6)',
border: '2px solid rgba(34, 211, 238, 0.3)',
borderRadius: '16px',
padding: '24px',
textAlign: 'center' as const,
marginBottom: '24px'
},
code: {
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: '28px',
fontWeight: 700,
color: '#22d3ee',
letterSpacing: '2px'
},
warning: {
background: 'rgba(234, 179, 8, 0.1)',
border: '1px solid rgba(234, 179, 8, 0.3)',
borderRadius: '12px',
padding: '16px',
display: 'flex',
gap: '12px',
alignItems: 'flex-start',
marginBottom: '24px',
color: '#eab308'
},
checkbox: {
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '24px',
cursor: 'pointer',
color: '#a1a1aa'
},
error: {
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: '12px',
padding: '12px 16px',
color: '#ef4444',
fontSize: '14px',
marginBottom: '16px',
textAlign: 'center' as const
}
};
// ===== COMPONENTS =====
/**
* Welcome Screen - First screen user sees
*/
const WelcomeScreen: React.FC<{
onNewUser: () => void;
onHasCode: () => void;
}> = ({ onNewUser, onHasCode }) => (
<div style={styles.card}>
<div style={styles.logo}>
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 4L4 14v20l20 10 20-10V14L24 4z" fill="#000" fillOpacity="0.3" />
<path d="M24 8L8 16v16l16 8 16-8V16L24 8z" stroke="#fff" strokeWidth="2" />
<circle cx="24" cy="24" r="8" fill="#fff" />
</svg>
</div>
<h1 style={styles.title}>Welcome to Goose Ultra</h1>
<p style={styles.subtitle}>Your personal AI-powered development environment</p>
<button style={styles.button} onClick={onNewUser}>
<Icons.User />
I'm new here
</button>
<button style={styles.buttonSecondary} onClick={onHasCode}>
<Icons.Key />
I have a secret code
</button>
</div>
);
/**
* Login Screen - Enter secret code
*/
const LoginScreen: React.FC<{
onLogin: (code: string) => Promise<boolean>;
onBack: () => void;
}> = ({ onLogin, onBack }) => {
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!code.trim()) return;
setLoading(true);
setError('');
const success = await onLogin(code.trim());
if (!success) {
setError('Invalid secret code. Please check and try again.');
}
setLoading(false);
};
return (
<div style={styles.card}>
<h1 style={styles.title}>Welcome Back</h1>
<p style={styles.subtitle}>Enter your secret code to continue</p>
{error && <div style={styles.error}>{error}</div>}
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="GU-XXXX-XXXX-XXXX"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
style={{
...styles.input,
fontFamily: "'JetBrains Mono', monospace",
letterSpacing: '1px',
textAlign: 'center',
fontSize: '18px'
}}
autoFocus
/>
<button
type="submit"
style={{ ...styles.button, opacity: loading ? 0.7 : 1 }}
disabled={loading}
>
{loading ? 'Verifying...' : 'Login'}
<Icons.ArrowRight />
</button>
</form>
<button style={styles.buttonSecondary} onClick={onBack}>
Back
</button>
</div>
);
};
/**
* Onboarding Step 1 - Enter Name
*/
const OnboardingName: React.FC<{
onNext: (name: string) => void;
onBack: () => void;
}> = ({ onNext, onBack }) => {
const [name, setName] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim().length >= 2) {
onNext(name.trim());
}
};
return (
<div style={styles.card}>
<h1 style={styles.title}>What's your name?</h1>
<p style={styles.subtitle}>This will be displayed in your profile</p>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Enter your display name"
value={name}
onChange={(e) => setName(e.target.value)}
style={styles.input}
autoFocus
minLength={2}
maxLength={50}
/>
<button
type="submit"
style={{ ...styles.button, opacity: name.length < 2 ? 0.5 : 1 }}
disabled={name.length < 2}
>
Continue
<Icons.ArrowRight />
</button>
</form>
<button style={styles.buttonSecondary} onClick={onBack}>
Back
</button>
</div>
);
};
/**
* Onboarding Step 2 - Secret Question
*/
const OnboardingQuestion: React.FC<{
questions: SecretQuestion[];
onNext: (questionId: string, answer: string) => void;
onBack: () => void;
}> = ({ questions, onNext, onBack }) => {
const [questionId, setQuestionId] = useState('');
const [answer, setAnswer] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (questionId && answer.trim().length >= 2) {
onNext(questionId, answer.trim());
}
};
return (
<div style={styles.card}>
<h1 style={styles.title}>Set a Security Question</h1>
<p style={styles.subtitle}>This helps generate your unique secret code</p>
<form onSubmit={handleSubmit}>
<select
value={questionId}
onChange={(e) => setQuestionId(e.target.value)}
style={styles.select}
>
<option value="">Select a question...</option>
{questions.map(q => (
<option key={q.id} value={q.id}>{q.question}</option>
))}
</select>
<input
type="text"
placeholder="Your answer"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
style={styles.input}
minLength={2}
/>
<button
type="submit"
style={{ ...styles.button, opacity: (!questionId || answer.length < 2) ? 0.5 : 1 }}
disabled={!questionId || answer.length < 2}
>
Generate Secret Code
<Icons.Key />
</button>
</form>
<button style={styles.buttonSecondary} onClick={onBack}>
Back
</button>
</div>
);
};
/**
* Secret Code Reveal
*/
const SecretCodeReveal: React.FC<{
code: string;
userName: string;
onContinue: () => void;
}> = ({ code, userName, onContinue }) => {
const [copied, setCopied] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const copyCode = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.error('Failed to copy:', e);
}
};
return (
<div style={styles.card}>
<div style={{ textAlign: 'center', marginBottom: '16px' }}>
<span style={{ fontSize: '48px' }}>🎉</span>
</div>
<h1 style={styles.title}>Welcome, {userName}!</h1>
<p style={styles.subtitle}>Your secret code is ready</p>
<div style={styles.codeBox}>
<div style={styles.code}>{code}</div>
<button
onClick={copyCode}
style={{
marginTop: '16px',
padding: '10px 20px',
background: copied ? 'rgba(34, 197, 94, 0.2)' : 'rgba(255, 255, 255, 0.1)',
border: `1px solid ${copied ? '#22c55e' : 'rgba(255, 255, 255, 0.2)'}`,
borderRadius: '8px',
color: copied ? '#22c55e' : '#fff',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
fontSize: '14px',
transition: 'all 0.2s'
}}
>
{copied ? <Icons.Check /> : <Icons.Copy />}
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
</div>
<div style={styles.warning}>
<Icons.Alert />
<div>
<strong style={{ display: 'block', marginBottom: '4px' }}>SAVE THIS CODE OFFLINE!</strong>
<span style={{ fontSize: '13px', opacity: 0.9 }}>
This is the ONLY way to log back in. We cannot recover it if you lose it.
</span>
</div>
</div>
<label style={styles.checkbox} onClick={() => setConfirmed(!confirmed)}>
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
/>
<span>I have saved my code securely</span>
</label>
<button
style={{ ...styles.button, opacity: confirmed ? 1 : 0.5 }}
onClick={onContinue}
disabled={!confirmed}
>
Continue to Goose Ultra
<Icons.ArrowRight />
</button>
</div>
);
};
/**
* Logout Dialog
*/
export const LogoutDialog: React.FC<{
isOpen: boolean;
onClose: () => void;
onLogout: (cleanData: boolean) => void;
userName: string;
stats: { projectCount: number; chatCount: number; totalSizeBytes: number };
}> = ({ isOpen, onClose, onLogout, userName, stats }) => {
if (!isOpen) return null;
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000
}}>
<div style={{ ...styles.card, maxWidth: '420px' }}>
<h2 style={{ ...styles.title, fontSize: '22px' }}>Logging Out</h2>
<p style={{ ...styles.subtitle, marginBottom: '24px' }}>
Goodbye, {userName}!
</p>
<div style={{
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '12px',
padding: '16px',
marginBottom: '24px'
}}>
<div style={{ fontSize: '13px', color: '#a1a1aa', marginBottom: '12px' }}>
Your data on this device:
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#fff', fontSize: '14px', marginBottom: '8px' }}>
<span>Projects</span>
<span>{stats.projectCount}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#fff', fontSize: '14px', marginBottom: '8px' }}>
<span>Chat History</span>
<span>{stats.chatCount}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#fff', fontSize: '14px' }}>
<span>Total Size</span>
<span>{formatSize(stats.totalSizeBytes)}</span>
</div>
</div>
<button
style={styles.button}
onClick={() => onLogout(false)}
>
<Icons.Logout />
Keep Data & Logout
</button>
<button
style={{
...styles.buttonSecondary,
borderColor: 'rgba(239, 68, 68, 0.3)',
color: '#ef4444'
}}
onClick={() => onLogout(true)}
>
<Icons.Trash />
Clean Data & Logout
</button>
<button
style={{ ...styles.buttonSecondary, marginTop: '8px' }}
onClick={onClose}
>
Cancel
</button>
</div>
</div>
);
};
// ===== MAIN LOGIN GATE =====
type Screen = 'welcome' | 'login' | 'onboarding-name' | 'onboarding-question' | 'code-reveal';
export const LoginGate: React.FC<{ children: ReactNode }> = ({ children }) => {
const [screen, setScreen] = useState<Screen>('welcome');
const [session, setSession] = useState<UserSession | null>(null);
const [user, setUser] = useState<GooseUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [questions, setQuestions] = useState<SecretQuestion[]>([]);
// Onboarding state
const [onboardingName, setOnboardingName] = useState('');
const [generatedCode, setGeneratedCode] = useState('');
// Check for existing session on mount
useEffect(() => {
const checkSession = async () => {
const electron = (window as any).electron;
if (!electron?.user) {
// No electron API (web mode) - skip auth
setIsLoading(false);
return;
}
try {
const existingSession = await electron.user.getSession();
if (existingSession) {
setSession(existingSession);
}
const questionsList = await electron.user.getSecretQuestions();
setQuestions(questionsList || []);
} catch (e) {
console.error('Failed to check session:', e);
}
setIsLoading(false);
};
checkSession();
}, []);
// Login handler
const handleLogin = async (code: string): Promise<boolean> => {
const electron = (window as any).electron;
if (!electron?.user) return false;
const result = await electron.user.login(code);
if (result.success) {
setSession(result.session);
setUser(result.user);
return true;
}
return false;
};
// Create user handler
const handleCreateUser = async (questionId: string, answer: string) => {
const electron = (window as any).electron;
if (!electron?.user) return;
const result = await electron.user.create(onboardingName, questionId, answer);
if (result.success) {
setGeneratedCode(result.secretCode);
setUser(result.user);
setSession(result.session);
setScreen('code-reveal');
}
};
// Logout handler
const handleLogout = async (cleanData = false) => {
const electron = (window as any).electron;
if (!electron?.user) return;
await electron.user.logout(cleanData);
setSession(null);
setUser(null);
setScreen('welcome');
};
// Refresh session
const refreshSession = async () => {
const electron = (window as any).electron;
if (!electron?.user) return;
const existingSession = await electron.user.getSession();
setSession(existingSession);
};
// Loading state
if (isLoading) {
return (
<div style={styles.container}>
<div style={{ color: '#fff', fontSize: '18px' }}>Loading...</div>
</div>
);
}
// If no electron (web mode) or has session, render children
const electron = (window as any).electron;
if (!electron?.user || session) {
return (
<UserContext.Provider value={{ session, user, isLoading, logout: handleLogout, refreshSession }}>
{children}
</UserContext.Provider>
);
}
// Render login/onboarding screens
return (
<div style={styles.container}>
{screen === 'welcome' && (
<WelcomeScreen
onNewUser={() => setScreen('onboarding-name')}
onHasCode={() => setScreen('login')}
/>
)}
{screen === 'login' && (
<LoginScreen
onLogin={handleLogin}
onBack={() => setScreen('welcome')}
/>
)}
{screen === 'onboarding-name' && (
<OnboardingName
onNext={(name) => {
setOnboardingName(name);
setScreen('onboarding-question');
}}
onBack={() => setScreen('welcome')}
/>
)}
{screen === 'onboarding-question' && (
<OnboardingQuestion
questions={questions}
onNext={handleCreateUser}
onBack={() => setScreen('onboarding-name')}
/>
)}
{screen === 'code-reveal' && (
<SecretCodeReveal
code={generatedCode}
userName={onboardingName}
onContinue={() => {
// Session already set, just clear screens
setScreen('welcome');
}}
/>
)}
</div>
);
};
export default LoginGate;

View File

@@ -1445,41 +1445,50 @@ Start with "# 🔧 Repair Plan" and be concise.`;
</button> </button>
<button <button
onClick={async () => { onClick={async () => {
if (confirm('Generate a Node.js/Express backend for this frontend?')) { if (confirm('Export this frontend as a UX Package for external backend development?')) {
const htmlContent = state.files['index.html'] || ''; const htmlContent = state.files['index.html'] || '';
const cssContent = state.files['style.css'] || '';
const jsContent = state.files['script.js'] || '';
const projectId = state.activeProject?.id || 'unknown_project';
if (!htmlContent) { if (!htmlContent) {
alert('No frontend code found to analyze.'); alert('No frontend code found to export.');
return; return;
} }
// Switch to Chat to show progress const uxPackage = {
dispatch({ type: 'SET_MODE', mode: GlobalMode.Chat }); meta: {
dispatch({ type: 'SET_TAB', tab: TabId.Plan }); project: state.activeProject?.name || "Vibe Project",
exportedAt: new Date().toISOString(),
generator: "Goose Ultra Vibe Engine",
description: "Frontend UX Package for Backend Integration"
},
files: {
"index.html": htmlContent,
"style.css": cssContent,
"script.js": jsContent
},
instructions: "This package contains the complete frontend UI. Use this to build the backend logic."
};
const prompt = `[BACKEND_REQUEST] const blob = new Blob([JSON.stringify(uxPackage, null, 2)], { type: 'application/json' });
I need a backend for my frontend. const url = URL.createObjectURL(blob);
Here is the current HTML/JS code: const a = document.createElement('a');
\`\`\`html a.href = url;
${htmlContent.substring(0, 15000)}... (truncated) a.download = `ux_package_${projectId}.json`;
\`\`\` document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
Please build a 'server.js' file using Express that: dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'system', message: "UX Package exported successfully." } });
1. Serves this static file.
2. Implements any API endpoints found in the fetch() calls.
3. Provides mock data for those endpoints.`;
dispatch({ type: 'START_REQUEST', sessionId: Date.now().toString(), messageDraft: prompt });
// We rely on the LayoutComponents handler to pick this up,
// but we need to ensure the system prompt knows how to handle it.
// Ideally, we'd invoke the automationService directly, but flowing through chat maintains state consistency.
} }
}} }}
className="ml-2 px-3 py-2 rounded-xl text-xs font-bold border border-white/10 bg-indigo-500/10 text-indigo-300 hover:bg-indigo-500/20 hover:text-white transition-colors flex items-center gap-2" className="ml-2 px-3 py-2 rounded-xl text-xs font-bold border border-white/10 bg-cyan-500/10 text-cyan-300 hover:bg-cyan-500/20 hover:text-white transition-colors flex items-center gap-2"
title="Generate Backend Service" title="Download Frontend for Backend Dev"
> >
<Icons.Server className="w-3.5 h-3.5" /> <Icons.Package className="w-3.5 h-3.5" />
Build Backend Generate UX Package
</button> </button>
</div> </div>

View File

@@ -126,4 +126,5 @@ export const Icons = {
Minimize2: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><polyline points="4 14 10 14 10 20" /><polyline points="20 10 14 10 14 4" /><line x1="14" y1="10" x2="21" y2="3" /><line x1="3" y1="21" x2="10" y2="14" /></svg>, Minimize2: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><polyline points="4 14 10 14 10 20" /><polyline points="20 10 14 10 14 4" /><line x1="14" y1="10" x2="21" y2="3" /><line x1="3" y1="21" x2="10" y2="14" /></svg>,
Maximize2: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>, Maximize2: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>,
Brain: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" /><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" /><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" /><path d="M17.599 6.5a3 3 0 0 0 .399-1.375" /><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" /><path d="M3.477 10.896a4 4 0 0 1 .585-.396" /><path d="M19.938 10.5a4 4 0 0 1 .585.396" /><path d="M6 18a4 4 0 0 1-1.967-.516" /><path d="M19.967 17.484A4 4 0 0 1 18 18" /></svg>, Brain: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" /><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" /><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" /><path d="M17.599 6.5a3 3 0 0 0 .399-1.375" /><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" /><path d="M3.477 10.896a4 4 0 0 1 .585-.396" /><path d="M19.938 10.5a4 4 0 0 1 .585.396" /><path d="M6 18a4 4 0 0 1-1.967-.516" /><path d="M19.967 17.484A4 4 0 0 1 18 18" /></svg>,
Package: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M16.5 9.4 7.5 4.21" /><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><polyline points="3.27 6.96 12 12.01 20.73 6.96" /><line x1="12" y1="22.08" x2="12" y2="12" /></svg>,
}; };

View File

@@ -83,26 +83,6 @@ Example Output:
</artifact_payload> </artifact_payload>
`; `;
export const BACKEND_GENERATOR_PROMPT = `
You are an expert Backend Engineer. Your task is to build a robust Node.js/Express backend for the provided frontend code.
### ANALYSIS PHASE
1. **Endpoint Extraction**: Scan the frontend code for all 'fetch', 'axios', or 'XMLHttpRequest' calls. Identify the methods (GET, POST, etc.) and paths (e.g., /api/users).
2. **Data Modeling**: Infer the data structure expected by these endpoints based on how the frontend uses the response (e.g., if it maps over 'response.data.users', create a 'users' array).
### IMPLEMENTATION REQUIREMENTS
1. **Single File**: Output a single 'server.js' file.
2. **Express.js**: Use Express as the framework.
3. **Mock Data**: Create realistic mock data to populate the endpoints.
4. **CORS**: Enable CORS to allow the frontend to connect (if running separately) or serve the static frontend file if requested.
5. **Static Serving**: Include code to serve 'index.html' from the current directory.
### OUTPUT FORMAT
- Return ONLY the raw JavaScript code for 'server.js'.
- Start with 'const express = require("express");'.
- Do NOT wrap in markdown blocks.
`;
export const MockComputerDriver: GooseUltraComputerDriver = { export const MockComputerDriver: GooseUltraComputerDriver = {

1
qwen-code-reference Submodule

Submodule qwen-code-reference added at a92be72e88