feat: add Unsplash search hook and API proxy routes

- Implemented `useUnsplashSearch` hook for searching Unsplash photos with debounce.
- Created Unsplash API client functions for searching photos and triggering downloads.
- Added proxy routes for Unsplash API to handle search requests and background image uploads.
- Introduced accessibility utilities for WCAG compliance checks and motion preference management.
- Developed theme sharing module for encoding and decoding theme configurations as base64url strings.
This commit is contained in:
catlog22
2026-02-08 20:01:28 +08:00
parent 87daccdc48
commit 166211dcd4
52 changed files with 5798 additions and 142 deletions

View File

@@ -65,9 +65,37 @@ export const QuestionAnswerSchema = z.object({
export type QuestionAnswer = z.infer<typeof QuestionAnswerSchema>;
// ========== AskUserQuestion-style Types ==========
/** AskUserQuestion-style option (value auto-generated from label) */
export const SimpleOptionSchema = z.object({
label: z.string(),
description: z.string().optional(),
});
export type SimpleOption = z.infer<typeof SimpleOptionSchema>;
/** AskUserQuestion-style question */
export const SimpleQuestionSchema = z.object({
question: z.string(), // 问题文本 → 映射到 title
header: z.string(), // 短标签 → 映射到 id
multiSelect: z.boolean().default(false),
options: z.array(SimpleOptionSchema).optional(),
});
export type SimpleQuestion = z.infer<typeof SimpleQuestionSchema>;
/** 新格式参数 (questions 数组) */
export const AskQuestionSimpleParamsSchema = z.object({
questions: z.array(SimpleQuestionSchema).min(1).max(4),
timeout: z.number().optional(),
});
export type AskQuestionSimpleParams = z.infer<typeof AskQuestionSimpleParamsSchema>;
// ========== Ask Question Parameters ==========
/** Parameters for ask_question tool */
/** Parameters for ask_question tool (legacy format) */
export const AskQuestionParamsSchema = z.object({
question: QuestionSchema,
timeout: z.number().optional().default(300000), // 5 minutes default

View File

@@ -4,10 +4,13 @@
// WebSocket transport for A2UI surfaces and actions
import type { Duplex } from 'stream';
import http from 'http';
import type { IncomingMessage } from 'http';
import { createWebSocketFrame, parseWebSocketFrame, wsClients } from '../websocket.js';
import type { QuestionAnswer, AskQuestionParams, Question } from './A2UITypes.js';
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
// ========== A2UI Message Types ==========
/** A2UI WebSocket message types */
@@ -60,8 +63,20 @@ export class A2UIWebSocketHandler {
private multiSelectSelections = new Map<string, Set<string>>();
private singleSelectSelections = new Map<string, string>();
private inputValues = new Map<string, string>();
/** Answers resolved by Dashboard but not yet consumed by MCP polling */
private resolvedAnswers = new Map<string, { answer: QuestionAnswer; timestamp: number }>();
private resolvedMultiAnswers = new Map<string, { compositeId: string; answers: QuestionAnswer[]; timestamp: number }>();
private answerCallback?: (answer: QuestionAnswer) => boolean;
private multiAnswerCallback?: (compositeId: string, answers: QuestionAnswer[]) => boolean;
/** Buffered surfaces waiting to be replayed to newly connected clients */
private pendingSurfaces: Array<{
surfaceUpdate: { surfaceId: string; components: unknown[]; initialState: Record<string, unknown>; displayMode?: 'popup' | 'panel' };
message: unknown;
}> = [];
/**
* Register callback for handling question answers
@@ -71,6 +86,14 @@ export class A2UIWebSocketHandler {
this.answerCallback = callback;
}
/**
* Register callback for handling multi-question composite answers (submit-all)
* @param callback - Function to handle composite answers
*/
registerMultiAnswerCallback(callback: (compositeId: string, answers: QuestionAnswer[]) => boolean): void {
this.multiAnswerCallback = callback;
}
/**
* Get the registered answer callback
*/
@@ -78,6 +101,20 @@ export class A2UIWebSocketHandler {
return this.answerCallback;
}
/**
* Initialize multi-select tracking for a question (used by multi-page surfaces)
*/
initMultiSelect(questionId: string): void {
this.multiSelectSelections.set(questionId, new Set<string>());
}
/**
* Initialize single-select tracking for a question (used by multi-page surfaces)
*/
initSingleSelect(questionId: string): void {
this.singleSelectSelections.set(questionId, '');
}
/**
* Send A2UI surface to all connected clients
* @param surfaceUpdate - A2UI surface update to send
@@ -115,6 +152,13 @@ export class A2UIWebSocketHandler {
}
}
// No local WebSocket clients — forward via HTTP to Dashboard server
// (Happens when running in MCP stdio process, separate from Dashboard)
if (wsClients.size === 0) {
this.forwardSurfaceViaDashboard(surfaceUpdate);
return 0;
}
// Broadcast to all clients
const frame = createWebSocketFrame(message);
let sentCount = 0;
@@ -132,6 +176,72 @@ export class A2UIWebSocketHandler {
return sentCount;
}
/**
* Replay buffered surfaces to a newly connected client, then clear the buffer.
* @param client - The newly connected WebSocket client
* @returns Number of surfaces replayed
*/
replayPendingSurfaces(client: Duplex): number {
if (this.pendingSurfaces.length === 0) {
return 0;
}
const count = this.pendingSurfaces.length;
for (const { surfaceUpdate, message } of this.pendingSurfaces) {
try {
const frame = createWebSocketFrame(message);
client.write(frame);
} catch (e) {
console.error(`[A2UI] Failed to replay surface ${surfaceUpdate.surfaceId}:`, e);
}
}
console.log(`[A2UI] Replayed ${count} buffered surface(s) to new client`);
this.pendingSurfaces = [];
return count;
}
/**
* Forward surface to Dashboard server via HTTP POST /api/hook.
* Used when running in a separate process (MCP stdio) without local WebSocket clients.
*/
private forwardSurfaceViaDashboard(surfaceUpdate: {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
displayMode?: 'popup' | 'panel';
}): void {
// Send flat so the hook handler wraps it as { type, payload: { ...fields } }
// which matches the frontend's expected format: data.type === 'a2ui-surface' && data.payload
const body = JSON.stringify({
type: 'a2ui-surface',
surfaceId: surfaceUpdate.surfaceId,
components: surfaceUpdate.components,
initialState: surfaceUpdate.initialState,
displayMode: surfaceUpdate.displayMode,
});
const req = http.request({
hostname: 'localhost',
port: DASHBOARD_PORT,
path: '/api/hook',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
});
req.on('error', (err) => {
console.error(`[A2UI] Failed to forward surface ${surfaceUpdate.surfaceId} to Dashboard:`, err.message);
});
req.write(body);
req.end();
console.log(`[A2UI] Forwarded surface ${surfaceUpdate.surfaceId} to Dashboard via HTTP`);
}
/**
* Send A2UI surface to specific client
* @param client - Specific WebSocket client
@@ -226,12 +336,16 @@ export class A2UIWebSocketHandler {
const resolveAndCleanup = (answer: QuestionAnswer): boolean => {
const handled = answerCallback(answer);
if (handled) {
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
this.singleSelectSelections.delete(questionId);
if (!handled) {
// answerCallback couldn't deliver (MCP process has no local pendingQuestions)
// Store answer for HTTP polling retrieval
this.resolvedAnswers.set(questionId, { answer, timestamp: Date.now() });
}
return handled;
// Always clean up UI state regardless of delivery
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
this.singleSelectSelections.delete(questionId);
return true;
};
switch (action.actionId) {
@@ -278,15 +392,88 @@ export class A2UIWebSocketHandler {
}
case 'submit': {
const otherText = this.inputValues.get(`__other__:${questionId}`);
// Check if this is a single-select or multi-select
const singleSelection = this.singleSelectSelections.get(questionId);
if (singleSelection !== undefined) {
// Single-select submit
return resolveAndCleanup({ questionId, value: singleSelection, cancelled: false });
// Resolve __other__ to actual text input
const value = singleSelection === '__other__' && otherText ? otherText : singleSelection;
this.inputValues.delete(`__other__:${questionId}`);
return resolveAndCleanup({ questionId, value, cancelled: false });
}
// Multi-select submit
const multiSelected = this.multiSelectSelections.get(questionId) ?? new Set<string>();
return resolveAndCleanup({ questionId, value: Array.from(multiSelected), cancelled: false });
// Resolve __other__ in multi-select: replace with actual text
const values = Array.from(multiSelected).map(v =>
v === '__other__' && otherText ? otherText : v
);
this.inputValues.delete(`__other__:${questionId}`);
return resolveAndCleanup({ questionId, value: values, cancelled: false });
}
case 'input-change': {
// Track text input value for multi-page surfaces
const value = params.value;
if (typeof value !== 'string') {
return false;
}
this.inputValues.set(questionId, value);
return true;
}
case 'submit-all': {
// Multi-question composite submit
const compositeId = typeof params.compositeId === 'string' ? params.compositeId : undefined;
const questionIds = Array.isArray(params.questionIds) ? params.questionIds as string[] : undefined;
if (!compositeId || !questionIds) {
return false;
}
// Collect answers for all sub-questions
const answers: QuestionAnswer[] = [];
for (const qId of questionIds) {
const singleSel = this.singleSelectSelections.get(qId);
const multiSel = this.multiSelectSelections.get(qId);
const inputVal = this.inputValues.get(qId);
const otherText = this.inputValues.get(`__other__:${qId}`);
if (singleSel !== undefined) {
// Resolve __other__ to actual text input
const value = singleSel === '__other__' && otherText ? otherText : singleSel;
answers.push({ questionId: qId, value, cancelled: false });
} else if (multiSel !== undefined) {
// Resolve __other__ in multi-select: replace with actual text
const values = Array.from(multiSel).map(v =>
v === '__other__' && otherText ? otherText : v
);
answers.push({ questionId: qId, value: values, cancelled: false });
} else if (inputVal !== undefined) {
answers.push({ questionId: qId, value: inputVal, cancelled: false });
} else {
// No value recorded — include empty
answers.push({ questionId: qId, value: '', cancelled: false });
}
// Cleanup per-question tracking
this.singleSelectSelections.delete(qId);
this.multiSelectSelections.delete(qId);
this.inputValues.delete(qId);
this.inputValues.delete(`__other__:${qId}`);
}
// Call multi-answer callback
let handled = false;
if (this.multiAnswerCallback) {
handled = this.multiAnswerCallback(compositeId, answers);
}
if (!handled) {
// Store for HTTP polling retrieval
this.resolvedMultiAnswers.set(compositeId, { compositeId, answers, timestamp: Date.now() });
}
// Always clean up UI state
this.activeSurfaces.delete(compositeId);
return true;
}
default:
@@ -324,6 +511,7 @@ export class A2UIWebSocketHandler {
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
this.inputValues.delete(questionId);
return true;
}
@@ -346,6 +534,32 @@ export class A2UIWebSocketHandler {
this.activeSurfaces.clear();
}
/**
* Get and remove a resolved answer (one-shot read).
* Used by MCP HTTP polling to retrieve answers stored by the Dashboard.
*/
getResolvedAnswer(questionId: string): QuestionAnswer | undefined {
const entry = this.resolvedAnswers.get(questionId);
if (entry) {
this.resolvedAnswers.delete(questionId);
return entry.answer;
}
return undefined;
}
/**
* Get and remove a resolved multi-answer (one-shot read).
* Used by MCP HTTP polling to retrieve composite answers stored by the Dashboard.
*/
getResolvedMultiAnswer(compositeId: string): QuestionAnswer[] | undefined {
const entry = this.resolvedMultiAnswers.get(compositeId);
if (entry) {
this.resolvedMultiAnswers.delete(compositeId);
return entry.answers;
}
return undefined;
}
/**
* Remove stale surfaces (older than specified time)
* @param maxAge - Maximum age in milliseconds
@@ -362,6 +576,18 @@ export class A2UIWebSocketHandler {
}
}
// Clean up stale resolved answers
for (const [id, entry] of this.resolvedAnswers) {
if (now - entry.timestamp > maxAge) {
this.resolvedAnswers.delete(id);
}
}
for (const [id, entry] of this.resolvedMultiAnswers) {
if (now - entry.timestamp > maxAge) {
this.resolvedMultiAnswers.delete(id);
}
}
return removed;
}
}

View File

@@ -259,7 +259,8 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
// Build CLI arguments based on index type
// Use 'index init' subcommand (new CLI structure)
const args = ['index', 'init', targetPath, '--json'];
// --force flag ensures full reindex (not incremental)
const args = ['index', 'init', targetPath, '--force', '--json'];
if (resolvedIndexType === 'normal') {
args.push('--no-embeddings');
} else {
@@ -380,8 +381,10 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
}
}
// Build CLI arguments for incremental update using 'index update' subcommand
const args = ['index', 'update', targetPath, '--json'];
// Build CLI arguments for incremental update using 'index init' without --force
// 'index init' defaults to incremental mode (skip unchanged files)
// 'index update' is only for single-file updates in hooks
const args = ['index', 'init', targetPath, '--json'];
if (resolvedIndexType === 'normal') {
args.push('--no-embeddings');
} else {

View File

@@ -8,6 +8,8 @@ import {
executeCodexLens,
installSemantic,
} from '../../../tools/codex-lens.js';
import { getCodexLensPython } from '../../../utils/codexlens-path.js';
import { spawn } from 'child_process';
import type { GpuMode } from '../../../tools/codex-lens.js';
import { loadLiteLLMApiConfig, getAvailableModelsForType, getProvider, getAllProviders } from '../../../config/litellm-api-config-manager.js';
import {
@@ -19,6 +21,86 @@ import { extractJSON } from './utils.js';
import { getDefaultTool } from '../../../tools/claude-cli-tools.js';
import { getCodexLensDataDir } from '../../../utils/codexlens-path.js';
/**
* Execute CodexLens Python API call directly (bypasses CLI for richer API access).
*/
async function executeCodexLensPythonAPI(
apiFunction: string,
args: Record<string, unknown>,
timeout: number = 60000
): Promise<{ success: boolean; results?: unknown; error?: string }> {
return new Promise((resolve) => {
const pythonScript = `
import json
import sys
from dataclasses import is_dataclass, asdict
from codexlens.api import ${apiFunction}
def to_serializable(obj):
if obj is None:
return None
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
if isinstance(obj, list):
return [to_serializable(item) for item in obj]
if isinstance(obj, dict):
return {key: to_serializable(value) for key, value in obj.items()}
if isinstance(obj, tuple):
return tuple(to_serializable(item) for item in obj)
return obj
try:
args = ${JSON.stringify(args)}
result = ${apiFunction}(**args)
output = to_serializable(result)
print(json.dumps({"success": True, "result": output}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
`;
const pythonPath = getCodexLensPython();
const child = spawn(pythonPath, ['-c', pythonScript], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code !== 0) {
try {
const errorData = JSON.parse(stderr || stdout);
resolve({ success: false, error: errorData.error || 'Unknown error' });
} catch {
resolve({ success: false, error: stderr || stdout || `Process exited with code ${code}` });
}
return;
}
try {
const data = JSON.parse(stdout);
resolve({ success: data.success, results: data.result, error: data.error });
} catch (err) {
resolve({ success: false, error: `Failed to parse output: ${(err as Error).message}` });
}
});
child.on('error', (err) => {
resolve({ success: false, error: `Failed to execute: ${err.message}` });
});
});
}
export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
@@ -928,5 +1010,154 @@ except Exception as e:
return true;
}
// ============================================================
// LSP / SEMANTIC SEARCH API ENDPOINTS
// ============================================================
// API: LSP Status - Check if LSP/semantic search capabilities are available
if (pathname === '/api/codexlens/lsp/status') {
try {
const venvStatus = await checkVenvStatus();
if (!venvStatus.ready) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
available: false,
semantic_available: false,
vector_index: false,
error: 'CodexLens not installed'
}));
return true;
}
// Check semantic deps and vector index availability in parallel
const [semanticStatus, workspaceResult] = await Promise.all([
checkSemanticStatus(),
executeCodexLens(['status', '--json'])
]);
let hasVectorIndex = false;
let projectCount = 0;
let embeddingsInfo: Record<string, unknown> = {};
if (workspaceResult.success) {
try {
const status = extractJSON(workspaceResult.output ?? '');
if (status.success !== false && status.result) {
projectCount = status.result.projects_count || 0;
embeddingsInfo = status.result.embeddings || {};
// Check if any projects have embeddings
hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
} else if (status.projects_count !== undefined) {
projectCount = status.projects_count || 0;
embeddingsInfo = status.embeddings || {};
hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
}
} catch {
// Parse failed
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
available: semanticStatus.available && hasVectorIndex,
semantic_available: semanticStatus.available,
vector_index: hasVectorIndex,
project_count: projectCount,
embeddings: embeddingsInfo,
modes: ['fusion', 'vector', 'structural'],
strategies: ['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'],
}));
} catch (err: unknown) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
available: false,
semantic_available: false,
vector_index: false,
error: err instanceof Error ? err.message : String(err)
}));
}
return true;
}
// API: LSP Semantic Search - Advanced semantic search via Python API
if (pathname === '/api/codexlens/lsp/search' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const {
query,
path: projectPath,
mode = 'fusion',
fusion_strategy = 'rrf',
vector_weight = 0.5,
structural_weight = 0.3,
keyword_weight = 0.2,
kind_filter,
limit = 20,
include_match_reason = false,
} = body as {
query?: unknown;
path?: unknown;
mode?: unknown;
fusion_strategy?: unknown;
vector_weight?: unknown;
structural_weight?: unknown;
keyword_weight?: unknown;
kind_filter?: unknown;
limit?: unknown;
include_match_reason?: unknown;
};
const resolvedQuery = typeof query === 'string' ? query.trim() : '';
if (!resolvedQuery) {
return { success: false, error: 'Query parameter is required', status: 400 };
}
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
const resolvedMode = typeof mode === 'string' && ['fusion', 'vector', 'structural'].includes(mode) ? mode : 'fusion';
const resolvedStrategy = typeof fusion_strategy === 'string' &&
['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'].includes(fusion_strategy) ? fusion_strategy : 'rrf';
const resolvedVectorWeight = typeof vector_weight === 'number' ? vector_weight : 0.5;
const resolvedStructuralWeight = typeof structural_weight === 'number' ? structural_weight : 0.3;
const resolvedKeywordWeight = typeof keyword_weight === 'number' ? keyword_weight : 0.2;
const resolvedLimit = typeof limit === 'number' ? limit : 20;
const resolvedIncludeReason = typeof include_match_reason === 'boolean' ? include_match_reason : false;
// Build Python API call args
const apiArgs: Record<string, unknown> = {
project_root: targetPath,
query: resolvedQuery,
mode: resolvedMode,
vector_weight: resolvedVectorWeight,
structural_weight: resolvedStructuralWeight,
keyword_weight: resolvedKeywordWeight,
fusion_strategy: resolvedStrategy,
limit: resolvedLimit,
include_match_reason: resolvedIncludeReason,
};
if (Array.isArray(kind_filter) && kind_filter.length > 0) {
apiArgs.kind_filter = kind_filter;
}
try {
const result = await executeCodexLensPythonAPI('semantic_search', apiArgs);
if (result.success) {
return {
success: true,
results: result.results,
query: resolvedQuery,
mode: resolvedMode,
fusion_strategy: resolvedStrategy,
count: Array.isArray(result.results) ? result.results.length : 0,
};
} else {
return { success: false, error: result.error || 'Semantic search failed', status: 500 };
}
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
return false;
}

View File

@@ -1197,7 +1197,7 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
// Parse enabled tools from request body
const enabledTools = Array.isArray(body.enabledTools) && body.enabledTools.length > 0
? (body.enabledTools as string[]).join(',')
: 'write_file,edit_file,read_file,core_memory,ask_question';
: 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
// Generate CCW MCP server config
// Use cmd /c on Windows to inherit Claude Code's working directory

View File

@@ -648,5 +648,104 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
return true;
}
// API: Test multi-page ask_question popup (for development testing)
if (pathname === '/api/test/ask-question-multi' && req.method === 'GET') {
try {
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
const compositeId = `multi-${Date.now()}`;
const surfaceId = `question-${compositeId}`;
const testSurface = {
surfaceId,
components: [
// Page 0: Select question
{ id: 'page-0-title', page: 0, component: { Text: { text: { literalString: 'Which framework?' }, usageHint: 'h3' } } },
{ id: 'page-0-radio-group', page: 0, component: {
RadioGroup: {
options: [
{ label: { literalString: 'React' }, value: 'React', description: { literalString: 'UI library' } },
{ label: { literalString: 'Vue' }, value: 'Vue', description: { literalString: 'Progressive framework' } },
{ label: { literalString: 'Other' }, value: '__other__', description: { literalString: 'Provide a custom answer' } },
],
onChange: { actionId: 'select', parameters: { questionId: 'Framework' } },
},
}},
// Page 1: Multi-select question
{ id: 'page-1-title', page: 1, component: { Text: { text: { literalString: 'Which features?' }, usageHint: 'h3' } } },
{ id: 'page-1-checkbox-0', page: 1, component: {
Checkbox: { label: { literalString: 'Auth' }, description: { literalString: 'Authentication' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: 'Auth' } }, checked: { literalBoolean: false } },
}},
{ id: 'page-1-checkbox-1', page: 1, component: {
Checkbox: { label: { literalString: 'Cache' }, description: { literalString: 'Caching layer' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: 'Cache' } }, checked: { literalBoolean: false } },
}},
{ id: 'page-1-checkbox-other', page: 1, component: {
Checkbox: { label: { literalString: 'Other' }, description: { literalString: 'Provide a custom answer' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: '__other__' } }, checked: { literalBoolean: false } },
}},
],
initialState: {
questionId: compositeId,
questionType: 'multi-question',
pages: [
{ index: 0, questionId: 'Framework', title: 'Which framework?', type: 'select' },
{ index: 1, questionId: 'Features', title: 'Which features?', type: 'multi-select' },
],
totalPages: 2,
},
displayMode: 'popup' as const,
};
const sentCount = a2uiWebSocketHandler.sendSurface(testSurface);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: 'Multi-page test popup sent',
sentToClients: sentCount,
surfaceId,
compositeId,
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to send test popup', details: String(err) }));
}
return true;
}
// API: A2UI answer broker — retrieve answers stored for MCP polling
if (pathname === '/api/a2ui/answer' && req.method === 'GET') {
const questionId = url.searchParams.get('questionId');
const isComposite = url.searchParams.get('composite') === 'true';
if (!questionId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'questionId is required' }));
return true;
}
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
if (isComposite) {
const answers = a2uiWebSocketHandler.getResolvedMultiAnswer(questionId);
if (answers) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: false, answers }));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: true }));
}
} else {
const answer = a2uiWebSocketHandler.getResolvedAnswer(questionId);
if (answer) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: false, answer }));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: true }));
}
}
return true;
}
return false;
}

View File

@@ -0,0 +1,238 @@
/**
* Unsplash Proxy Routes & Background Image Upload
* Proxies Unsplash API requests to keep API key server-side.
* API key is read from process.env.UNSPLASH_ACCESS_KEY.
* Also handles local background image upload and serving.
*/
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { randomBytes } from 'crypto';
import { homedir } from 'os';
import type { RouteContext } from './types.js';
const UNSPLASH_API = 'https://api.unsplash.com';
// Background upload config
const UPLOADS_DIR = join(homedir(), '.ccw', 'uploads', 'backgrounds');
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const EXT_MAP: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
};
const MIME_MAP: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
};
function getAccessKey(): string | undefined {
return process.env.UNSPLASH_ACCESS_KEY;
}
interface UnsplashPhoto {
id: string;
urls: { thumb: string; small: string; regular: string };
user: { name: string; links: { html: string } };
links: { html: string; download_location: string };
blur_hash: string | null;
}
interface UnsplashSearchResult {
results: UnsplashPhoto[];
total: number;
total_pages: number;
}
export async function handleBackgroundRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, req, res } = ctx;
// POST /api/background/upload
if (pathname === '/api/background/upload' && req.method === 'POST') {
const contentType = req.headers['content-type'] || '';
if (!ALLOWED_TYPES.has(contentType)) {
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unsupported image type. Only JPEG, PNG, WebP, GIF allowed.' }));
return true;
}
try {
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of req) {
totalSize += chunk.length;
if (totalSize > MAX_UPLOAD_SIZE) {
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File too large. Maximum size is 10MB.' }));
return true;
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
const ext = EXT_MAP[contentType] || 'bin';
const filename = `${Date.now()}-${randomBytes(4).toString('hex')}.${ext}`;
mkdirSync(UPLOADS_DIR, { recursive: true });
writeFileSync(join(UPLOADS_DIR, filename), buffer);
const url = `/api/background/uploads/${filename}`;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ url, filename }));
} catch (err) {
console.error('[background] Upload error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Upload failed' }));
}
return true;
}
// GET /api/background/uploads/:filename
if (pathname.startsWith('/api/background/uploads/') && req.method === 'GET') {
const filename = pathname.slice('/api/background/uploads/'.length);
// Security: reject path traversal
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid filename' }));
return true;
}
const filePath = join(UPLOADS_DIR, filename);
if (!existsSync(filePath)) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not found' }));
return true;
}
const ext = filename.split('.').pop()?.toLowerCase() || '';
const mime = MIME_MAP[ext] || 'application/octet-stream';
try {
const data = readFileSync(filePath);
res.writeHead(200, {
'Content-Type': mime,
'Content-Length': data.length,
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
} catch (err) {
console.error('[background] Serve error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to read file' }));
}
return true;
}
return false;
}
export async function handleUnsplashRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res } = ctx;
// GET /api/unsplash/search?query=...&page=1&per_page=20
if (pathname === '/api/unsplash/search' && req.method === 'GET') {
const accessKey = getAccessKey();
if (!accessKey) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
return true;
}
const query = url.searchParams.get('query') || '';
const page = url.searchParams.get('page') || '1';
const perPage = url.searchParams.get('per_page') || '20';
if (!query.trim()) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing query parameter' }));
return true;
}
try {
const apiUrl = `${UNSPLASH_API}/search/photos?query=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&orientation=landscape`;
const response = await fetch(apiUrl, {
headers: { Authorization: `Client-ID ${accessKey}` },
});
if (!response.ok) {
const status = response.status;
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Unsplash API error: ${status}` }));
return true;
}
const data = (await response.json()) as UnsplashSearchResult;
// Return simplified data
const photos = data.results.map((photo) => ({
id: photo.id,
thumbUrl: photo.urls.thumb,
smallUrl: photo.urls.small,
regularUrl: photo.urls.regular,
photographer: photo.user.name,
photographerUrl: photo.user.links.html,
photoUrl: photo.links.html,
blurHash: photo.blur_hash,
downloadLocation: photo.links.download_location,
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
photos,
total: data.total,
totalPages: data.total_pages,
}));
} catch (err) {
console.error('[unsplash] Search error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to search Unsplash' }));
}
return true;
}
// POST /api/unsplash/download — trigger download event (Unsplash API requirement)
if (pathname === '/api/unsplash/download' && req.method === 'POST') {
const accessKey = getAccessKey();
if (!accessKey) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
return true;
}
try {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = JSON.parse(Buffer.concat(chunks).toString());
const downloadLocation = body.downloadLocation;
if (!downloadLocation) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing downloadLocation' }));
return true;
}
// Trigger download event (Unsplash API guideline)
await fetch(downloadLocation, {
headers: { Authorization: `Client-ID ${accessKey}` },
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
console.error('[unsplash] Download trigger error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to trigger download' }));
}
return true;
}
return false;
}

View File

@@ -13,6 +13,7 @@ import { handleMemoryRoutes } from './routes/memory-routes.js';
import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js';
import { handleMcpRoutes } from './routes/mcp-routes.js';
import { handleHooksRoutes } from './routes/hooks-routes.js';
import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js';
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
import { handleGraphRoutes } from './routes/graph-routes.js';
import { handleSystemRoutes } from './routes/system-routes.js';
@@ -461,7 +462,7 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
const tokenManager = getTokenManager();
const secretKey = tokenManager.getSecretKey();
tokenManager.getOrCreateAuthToken();
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question']);
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
@@ -627,6 +628,16 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleHooksRoutes(routeContext)) return;
}
// Background image upload/serve routes (/api/background/*)
if (pathname.startsWith('/api/background/')) {
if (await handleBackgroundRoutes(routeContext)) return;
}
// Unsplash proxy routes (/api/unsplash/*)
if (pathname.startsWith('/api/unsplash/')) {
if (await handleUnsplashRoutes(routeContext)) return;
}
// CodexLens routes (/api/codexlens/*)
if (pathname.startsWith('/api/codexlens/')) {
if (await handleCodexLensRoutes(routeContext)) return;
@@ -728,11 +739,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleFilesRoutes(routeContext)) return;
}
// System routes (data, health, version, paths, shutdown, notify, storage, dialog)
// System routes (data, health, version, paths, shutdown, notify, storage, dialog, a2ui answer broker)
if (pathname === '/api/data' || pathname === '/api/health' ||
pathname === '/api/version-check' || pathname === '/api/shutdown' ||
pathname === '/api/recent-paths' || pathname === '/api/switch-path' ||
pathname === '/api/remove-recent-path' || pathname === '/api/system/notify' ||
pathname === '/api/a2ui/answer' ||
pathname.startsWith('/api/storage/') || pathname.startsWith('/api/dialog/')) {
if (await handleSystemRoutes(routeContext)) return;
}

View File

@@ -164,6 +164,9 @@ export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _he
wsClients.add(socket);
console.log(`[WS] Client connected (${wsClients.size} total)`);
// Replay any buffered A2UI surfaces to the new client
a2uiWebSocketHandler.replayPendingSurfaces(socket);
// Handle incoming messages
let pendingBuffer = Buffer.alloc(0);