| |
| |
| |
| |
| |
| |
| import path from 'node:path'; |
| import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync, statSync, appendFileSync, unlinkSync } from 'node:fs'; |
| import { |
| createSseResponse, |
| broadcastToVrm, |
| } from '../../friend/sse.js'; |
| import { getPrefs, setPrefs, updatePrefs, type FriendPrefs } from '../../friend/prefs.js'; |
| import { edgeTts, qwenTts, registerAudioFile, getAudioFile } from '../../friend/tts.js'; |
| import { stripForTts } from '../../friend/text-utils.js'; |
| import { friendChatService } from '../../friend/chat-service.js'; |
|
|
| const GATEWAY_URL = `http://127.0.0.1:3456`; |
|
|
| |
| let serverHost = '127.0.0.1'; |
| let serverPort = 3456; |
|
|
| export function setFriendServerInfo(host: string, port: number): void { |
| serverHost = host; |
| serverPort = port; |
| } |
|
|
| const MIME_TYPES: Record<string, string> = { |
| '.mp3': 'audio/mpeg', |
| '.opus': 'audio/opus', |
| '.ogg': 'audio/ogg', |
| '.wav': 'audio/wav', |
| '.webm': 'audio/webm', |
| }; |
|
|
| const IMAGE_MIME: Record<string, string> = { |
| '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', |
| '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', |
| '.bmp': 'image/bmp', |
| }; |
|
|
| |
| const homeDir = process.env.HOME || process.env.USERPROFILE || ''; |
| |
| let lastImportedDanceId: string | null = null; |
| const workspaceRoot = path.join(homeDir, '.config', 'VersperClaw', 'friend'); |
|
|
| |
| const identityPath = path.join(workspaceRoot, 'IDENTITY.md'); |
| const soulPath = path.join(workspaceRoot, 'SOUL.md'); |
|
|
| |
| const customModelsDir = path.join(workspaceRoot, 'models'); |
| const customDancesDir = path.join(workspaceRoot, 'dances'); |
|
|
| |
| const mediaFileRegistry = new Map<string, string>(); |
|
|
| function corsHeaders(): Record<string, string> { |
| return { |
| 'Access-Control-Allow-Origin': '*', |
| 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', |
| 'Access-Control-Allow-Headers': 'Content-Type', |
| }; |
| } |
|
|
| function jsonResponse(data: unknown, status = 200): Response { |
| return Response.json(data, { status, headers: corsHeaders() }); |
| } |
|
|
| function notFound(msg = 'Not found'): Response { |
| return jsonResponse({ error: msg }, 404); |
| } |
|
|
| function readJsonBody(req: Request): Promise<unknown> { |
| return req.json(); |
| } |
|
|
| |
| |
| |
| export async function handleFriendApi(req: Request, url: URL): Promise<Response> { |
| const pathname = url.pathname; |
| const method = req.method; |
|
|
| |
| if (method === 'OPTIONS') { |
| return new Response(null, { status: 204, headers: corsHeaders() }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/events' && method === 'GET') { |
| return createSseResponse(); |
| } |
|
|
| |
| if (pathname.startsWith('/plugins/friend/audio/') && method === 'GET') { |
| const audioId = pathname.split('/plugins/friend/audio/')[1]?.split('?')[0]; |
| if (!audioId) return jsonResponse({ error: 'missing audio id' }, 400); |
|
|
| const filePath = getAudioFile(audioId); |
| if (!filePath || !existsSync(filePath)) return notFound('audio not found'); |
|
|
| const ext = path.extname(filePath).toLowerCase(); |
| const contentType = MIME_TYPES[ext] ?? 'application/octet-stream'; |
| const data = readFileSync(filePath); |
| return new Response(data, { |
| headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=300', ...corsHeaders() }, |
| }); |
| } |
|
|
| |
| if (pathname.startsWith('/plugins/friend/media/') && method === 'GET') { |
| const mediaId = pathname.split('/plugins/friend/media/')[1]?.split('?')[0]; |
| if (!mediaId) return jsonResponse({ error: 'missing media id' }, 400); |
|
|
| const filePath = mediaFileRegistry.get(mediaId); |
| if (!filePath || !existsSync(filePath)) return notFound('media not found'); |
|
|
| const ext = path.extname(filePath).toLowerCase(); |
| const contentType = IMAGE_MIME[ext] ?? 'application/octet-stream'; |
| const data = readFileSync(filePath); |
| return new Response(data, { |
| headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=300', ...corsHeaders() }, |
| }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/chat' && method === 'POST') { |
| const body = await readJsonBody(req) as any; |
| const message = body?.message; |
| if (!message) return jsonResponse({ error: 'message required' }, 400); |
|
|
| |
| |
| (async () => { |
| try { |
| await friendChatService.ensureSession(serverHost, serverPort); |
| friendChatService.sendMessage(message); |
| } catch (err) { |
| console.error('[Friend] chat dispatch error:', err); |
| broadcastToVrm({ |
| text: `Connection error: ${err instanceof Error ? err.message : String(err)}`, |
| }); |
| broadcastToVrm({ replyDone: true }); |
| } |
| })(); |
|
|
| return jsonResponse({ ok: true }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/touch' && method === 'POST') { |
| const body = await readJsonBody(req) as any; |
| const region = body?.region; |
| const prompt = body?.prompt; |
| if (!region || !prompt) return jsonResponse({ error: 'region and prompt required' }, 400); |
|
|
| setImmediate(() => { |
| broadcastToVrm({ text: `[Touch: ${region}] ${prompt}` }); |
| }); |
|
|
| return jsonResponse({ ok: true }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/voice') { |
| if (method === 'GET') { |
| const prefs = getPrefs(); |
| return jsonResponse({ |
| voice: prefs.voice ?? 'zh-CN-XiaoxiaoNeural', |
| provider: prefs.provider ?? 'edge', |
| qwenKey: prefs.qwenKey ?? '', |
| qwenModel: prefs.qwenModel ?? 'qwen3-tts-flash', |
| }); |
| } |
|
|
| if (method === 'POST') { |
| const body = await readJsonBody(req) as any; |
| const patch: Partial<FriendPrefs> = {}; |
| if (body.voice !== undefined) patch.voice = body.voice || undefined; |
| if (body.provider !== undefined) patch.provider = body.provider || undefined; |
| if (body.qwenKey !== undefined) patch.qwenKey = body.qwenKey || undefined; |
| if (body.qwenModel !== undefined) patch.qwenModel = body.qwenModel || undefined; |
| setPrefs(updatePrefs(patch)); |
| return jsonResponse({ ok: true }); |
| } |
|
|
| return new Response(null, { status: 405 }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/preview' && method === 'POST') { |
| const body = await readJsonBody(req) as any; |
| const voice = body.voice as string | undefined; |
| const provider = body.provider as string | undefined; |
| const text = 'ไฝ ๅฅฝ๏ผ่ฟๆฏไธๆฎต่ฏญ้ณ่ฏๅฌใHello, this is a voice preview.'; |
|
|
| try { |
| const prefs = getPrefs(); |
| let result: { success: boolean; audioPath?: string; error?: string }; |
| if (provider === 'qwen' && prefs.qwenKey) { |
| result = await qwenTts({ |
| text, |
| apiKey: prefs.qwenKey, |
| voice, |
| model: prefs.qwenModel, |
| language: prefs.language, |
| }); |
| } else { |
| result = await edgeTts({ text, voice: voice || prefs.voice }); |
| } |
|
|
| if (result.success && result.audioPath) { |
| const audioId = registerAudioFile(result.audioPath); |
| return jsonResponse({ audioUrl: `${GATEWAY_URL}/plugins/friend/audio/${audioId}` }); |
| } |
| return jsonResponse({ error: result.error || 'TTS failed' }); |
| } catch (err) { |
| return jsonResponse({ error: String(err) }, 500); |
| } |
| } |
|
|
| |
| if (pathname === '/plugins/friend/settings') { |
| if (method === 'GET') { |
| const prefs = getPrefs(); |
| return jsonResponse({ |
| modelPath: prefs.modelPath, |
| ttsEnabled: prefs.ttsEnabled, |
| showText: prefs.showText, |
| hideUI: prefs.hideUI, |
| tracking: prefs.tracking, |
| volume: prefs.volume, |
| uiAlign: prefs.uiAlign, |
| screenObserve: prefs.screenObserve, |
| screenObserveInterval: prefs.screenObserveInterval, |
| currentDance: prefs.currentDance, |
| language: prefs.language, |
| hideMood: prefs.hideMood, |
| }); |
| } |
|
|
| if (method === 'POST') { |
| const body = await readJsonBody(req) as any; |
| const patch: Partial<FriendPrefs> = {}; |
| if (body.modelPath !== undefined) patch.modelPath = body.modelPath; |
| if (body.ttsEnabled !== undefined) patch.ttsEnabled = body.ttsEnabled; |
| if (body.showText !== undefined) patch.showText = body.showText; |
| if (body.hideUI !== undefined) patch.hideUI = body.hideUI; |
| if (body.tracking !== undefined) patch.tracking = body.tracking; |
| if (body.volume !== undefined) patch.volume = body.volume; |
| if (body.uiAlign !== undefined) patch.uiAlign = body.uiAlign; |
| if (body.screenObserve !== undefined) patch.screenObserve = body.screenObserve; |
| if (body.screenObserveInterval !== undefined) patch.screenObserveInterval = body.screenObserveInterval; |
| if (body.currentDance !== undefined) patch.currentDance = body.currentDance; |
| if (body.language !== undefined) patch.language = body.language; |
| if (body.hideMood !== undefined) patch.hideMood = body.hideMood; |
| setPrefs(updatePrefs(patch)); |
| return jsonResponse({ ok: true }); |
| } |
|
|
| return new Response(null, { status: 405 }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/persona') { |
| if (method === 'GET') { |
| let soul = ''; |
| let identity = ''; |
| try { if (existsSync(soulPath)) soul = readFileSync(soulPath, 'utf8'); } catch { } |
| try { if (existsSync(identityPath)) identity = readFileSync(identityPath, 'utf8'); } catch { } |
| return jsonResponse({ soul, identity, soulPath, identityPath }); |
| } |
|
|
| if (method === 'POST') { |
| const body = await readJsonBody(req) as any; |
| if (body.soul !== undefined) { |
| mkdirSync(path.dirname(soulPath), { recursive: true }); |
| writeFileSync(soulPath, body.soul, 'utf8'); |
| } |
| if (body.identity !== undefined) { |
| mkdirSync(path.dirname(identityPath), { recursive: true }); |
| writeFileSync(identityPath, body.identity, 'utf8'); |
| } |
| return jsonResponse({ ok: true }); |
| } |
|
|
| return new Response(null, { status: 405 }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/model/list' && method === 'GET') { |
| try { |
| let custom: string[] = []; |
| if (existsSync(customModelsDir)) { |
| custom = readdirSync(customModelsDir) |
| .filter((f: string) => f.toLowerCase().endsWith('.vrm')) |
| .map((f: string) => `${GATEWAY_URL}/plugins/friend/model/serve/${f}`); |
| } |
| return jsonResponse({ models: custom }); |
| } catch { |
| return jsonResponse({ models: [] }); |
| } |
| } |
|
|
| |
| if (pathname.startsWith('/plugins/friend/model/serve/') && method === 'GET') { |
| const fileName = decodeURIComponent(pathname.split('/plugins/friend/model/serve/')[1]?.split('?')[0] ?? ''); |
| if (!fileName || fileName.includes('..') || fileName.includes('/')) { |
| return jsonResponse({ error: 'invalid file name' }, 400); |
| } |
| const filePath = path.join(customModelsDir, fileName); |
| if (!existsSync(filePath)) return notFound('model not found'); |
|
|
| const data = readFileSync(filePath); |
| return new Response(data, { |
| headers: { |
| 'Content-Type': 'application/octet-stream', |
| 'Content-Length': String(data.length), |
| 'Cache-Control': 'public, max-age=3600', |
| ...corsHeaders(), |
| }, |
| }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/history' && method === 'GET') { |
| try { |
| const stateDir = path.join(homeDir, '.local', 'share', 'VersperClaw'); |
| const sessionsDir = path.join(stateDir, 'sessions'); |
|
|
| let messages: any[] = []; |
| if (existsSync(sessionsDir)) { |
| const files = readdirSync(sessionsDir) |
| .filter((f: string) => f.endsWith('.jsonl')) |
| .map((f: string) => ({ |
| name: f, |
| mtime: statSync(path.join(sessionsDir, f)).mtimeMs, |
| })) |
| .sort((a: any, b: any) => b.mtime - a.mtime); |
|
|
| if (files.length > 0) { |
| const jsonlPath = path.join(sessionsDir, files[0].name); |
| const lines = readFileSync(jsonlPath, 'utf8').split('\n').filter((l: string) => l.trim()); |
| for (const line of lines) { |
| try { |
| const obj = JSON.parse(line); |
| if (obj.message) messages.push(obj.message); |
| } catch { } |
| } |
| } |
| } |
|
|
| let agentName = ''; |
| try { |
| if (existsSync(identityPath)) { |
| const identity = readFileSync(identityPath, 'utf8'); |
| const nameMatch = identity.match(/\*\*Name:\*\*\s*(.+)/i) || identity.match(/^#\s+(.+)/m); |
| if (nameMatch) agentName = nameMatch[1].trim(); |
| } |
| } catch { } |
|
|
| const formatted = messages |
| .map((msg: any) => ({ |
| role: msg.role === 'model' ? 'assistant' : msg.role, |
| content: typeof msg.content === 'string' ? msg.content : '', |
| timestamp: msg.timestamp, |
| })) |
| .filter((m: any) => (m.role === 'user' || m.role === 'assistant') && m.content) |
| .slice(-100); |
|
|
| return jsonResponse({ messages: formatted, agentName: agentName || undefined }); |
| } catch (err) { |
| return jsonResponse({ error: String(err) }, 500); |
| } |
| } |
|
|
| |
| if (pathname === '/plugins/friend/context/clear' && method === 'POST') { |
| broadcastToVrm({ clearText: true }); |
| friendChatService.clearContext(); |
| return jsonResponse({ ok: true }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/mood/adjust' && method === 'POST') { |
| const body = await readJsonBody(req) as any; |
| const delta = Math.round(Number(body.delta) || 0); |
| if (delta === 0) return jsonResponse({ ok: true }); |
|
|
| const prefs = getPrefs(); |
| const currentMood = (prefs as any)._moodIndex ?? 60; |
| const cap = body.max != null ? Math.round(Number(body.max)) : 100; |
| const newMood = Math.max(0, Math.min(cap, currentMood + delta)); |
| (prefs as any)._moodIndex = newMood; |
| setPrefs(prefs); |
|
|
| broadcastToVrm({ moodDelta: delta, moodIndex: newMood }); |
| return jsonResponse({ ok: true, moodIndex: newMood }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/session/memo' && method === 'POST') { |
| const body = await readJsonBody(req) as any; |
| const text = typeof body.text === 'string' ? body.text.trim() : ''; |
| if (!text) return jsonResponse({ error: 'text required' }, 400); |
|
|
| try { |
| const stateDir = path.join(homeDir, '.local', 'share', 'VersperClaw'); |
| const sessionLog = path.join(stateDir, 'friend-memo.jsonl'); |
| mkdirSync(path.dirname(sessionLog), { recursive: true }); |
| appendFileSync(sessionLog, JSON.stringify({ role: 'user', content: text }) + '\n', 'utf8'); |
| return jsonResponse({ ok: true, written: true }); |
| } catch { |
| return jsonResponse({ ok: true, written: false }); |
| } |
| } |
|
|
| |
| if (pathname === '/plugins/friend/model/import' && method === 'POST') { |
| try { |
| const formData = await req.formData(); |
| const file = formData.get('file') as File | null; |
| if (!file) return jsonResponse({ error: 'file required' }, 400); |
|
|
| mkdirSync(customModelsDir, { recursive: true }); |
| const ext = path.extname(file.name) || '.vrm'; |
| const fileName = `${Date.now()}_${file.name.replace(/[^a-zA-Z0-9._-]/g, '_')}`; |
| const filePath = path.join(customModelsDir, fileName); |
|
|
| await Bun.write(filePath, file.stream()); |
| const serveUrl = `${GATEWAY_URL}/plugins/friend/model/serve/${encodeURIComponent(fileName)}`; |
| return jsonResponse({ ok: true, url: serveUrl }); |
| } catch (err) { |
| return jsonResponse({ error: String(err) }, 500); |
| } |
| } |
|
|
| |
| if (pathname === '/plugins/friend/dance/list' && method === 'GET') { |
| try { |
| const manifestPath = path.join(customDancesDir, 'index.json'); |
| const dances: Array<{ id: string; label: string; vmdUrl: string; bgmUrl?: string }> = []; |
| if (existsSync(manifestPath)) { |
| const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as Array<{ |
| id: string; label: string; vmdFile: string; bgmFile?: string; |
| }>; |
| for (const entry of manifest) { |
| dances.push({ |
| id: entry.id, |
| label: entry.label, |
| vmdUrl: `${GATEWAY_URL}/plugins/friend/dance/serve/${encodeURIComponent(entry.vmdFile)}`, |
| ...(entry.bgmFile ? { bgmUrl: `${GATEWAY_URL}/plugins/friend/dance/serve/${encodeURIComponent(entry.bgmFile)}` } : {}), |
| }); |
| } |
| } |
| return jsonResponse({ dances }); |
| } catch (err) { |
| return jsonResponse({ dances: [] }); |
| } |
| } |
|
|
| |
| if (pathname === '/plugins/friend/dance/import' && method === 'POST') { |
| try { |
| const formData = await req.formData(); |
| const file = formData.get('file') as File | null; |
| if (!file) return jsonResponse({ error: 'file required' }, 400); |
|
|
| mkdirSync(customDancesDir, { recursive: true }); |
| const ext = path.extname(file.name).toLowerCase(); |
| const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); |
|
|
| |
| const manifestPath = path.join(customDancesDir, 'index.json'); |
| let manifest: Array<{ id: string; label: string; vmdFile: string; bgmFile?: string }> = []; |
| if (existsSync(manifestPath)) { |
| try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { manifest = []; } |
| } |
|
|
| if (ext === '.vmd') { |
| const id = `dance_${Date.now()}`; |
| const vmdFile = `${id}.vmd`; |
| await Bun.write(path.join(customDancesDir, vmdFile), file.stream()); |
| const label = path.basename(file.name, '.vmd'); |
| manifest.push({ id, label, vmdFile }); |
| writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); |
| lastImportedDanceId = id; |
| return jsonResponse({ ok: true }); |
| } else if (ext === '.mp3') { |
| |
| const targetId = lastImportedDanceId; |
| if (!targetId) return jsonResponse({ error: 'no pending VMD import to associate with' }, 400); |
|
|
| const bgmFile = `${targetId}.mp3`; |
| await Bun.write(path.join(customDancesDir, bgmFile), file.stream()); |
|
|
| |
| const entry = manifest.find((e) => e.id === targetId); |
| if (entry) entry.bgmFile = bgmFile; |
| writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); |
| lastImportedDanceId = null; |
| return jsonResponse({ ok: true }); |
| } else { |
| return jsonResponse({ error: 'unsupported file type' }, 400); |
| } |
| } catch (err) { |
| return jsonResponse({ error: String(err) }, 500); |
| } |
| } |
|
|
| |
| if (pathname === '/plugins/friend/dance/delete' && method === 'POST') { |
| try { |
| const body = await readJsonBody(req) as any; |
| const id = body?.id as string | undefined; |
| if (!id) return jsonResponse({ error: 'id required' }, 400); |
|
|
| const manifestPath = path.join(customDancesDir, 'index.json'); |
| if (!existsSync(manifestPath)) return jsonResponse({ error: 'no dances' }, 404); |
|
|
| let manifest: Array<{ id: string; label: string; vmdFile: string; bgmFile?: string }> = []; |
| try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch { manifest = []; } |
|
|
| const idx = manifest.findIndex((e) => e.id === id); |
| if (idx === -1) return jsonResponse({ error: 'dance not found' }, 404); |
|
|
| const entry = manifest[idx]; |
| |
| const vmdPath = path.join(customDancesDir, entry.vmdFile); |
| if (existsSync(vmdPath)) unlinkSync(vmdPath); |
| if (entry.bgmFile) { |
| const bgmPath = path.join(customDancesDir, entry.bgmFile); |
| if (existsSync(bgmPath)) unlinkSync(bgmPath); |
| } |
|
|
| manifest.splice(idx, 1); |
| writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8'); |
| return jsonResponse({ ok: true }); |
| } catch (err) { |
| return jsonResponse({ error: String(err) }, 500); |
| } |
| } |
|
|
| |
| if (pathname.startsWith('/plugins/friend/dance/serve/') && method === 'GET') { |
| const fileName = decodeURIComponent(pathname.split('/plugins/friend/dance/serve/')[1]?.split('?')[0] ?? ''); |
| if (!fileName || fileName.includes('..') || fileName.includes('/')) { |
| return jsonResponse({ error: 'invalid file name' }, 400); |
| } |
| const filePath = path.join(customDancesDir, fileName); |
| if (!existsSync(filePath)) return notFound('dance file not found'); |
|
|
| const data = readFileSync(filePath); |
| const ext = path.extname(fileName).toLowerCase(); |
| const contentType = ext === '.mp3' ? 'audio/mpeg' : 'application/octet-stream'; |
| return new Response(data, { |
| headers: { |
| 'Content-Type': contentType, |
| 'Content-Length': String(data.length), |
| 'Cache-Control': 'public, max-age=3600', |
| ...corsHeaders(), |
| }, |
| }); |
| } |
|
|
| |
| if (pathname === '/plugins/friend/persona/screenshot' && method === 'POST') { |
| try { |
| const body = await readJsonBody(req) as any; |
| const image = body?.image as string | undefined; |
| if (!image) return jsonResponse({ error: 'image required' }, 400); |
|
|
| const screenshotDir = path.join(workspaceRoot, 'screenshots'); |
| mkdirSync(screenshotDir, { recursive: true }); |
|
|
| |
| const b64 = image.replace(/^data:image\/\w+;base64,/, ''); |
| const buf = Buffer.from(b64, 'base64'); |
| const screenshotPath = path.join(screenshotDir, 'vrm-screenshot.png'); |
| writeFileSync(screenshotPath, buf); |
|
|
| return jsonResponse({ ok: true, path: screenshotPath }); |
| } catch (err) { |
| return jsonResponse({ error: String(err) }, 500); |
| } |
| } |
|
|
| |
| if (pathname === '/plugins/friend/persona/generate' && method === 'POST') { |
| try { |
| const screenshotDir = path.join(workspaceRoot, 'screenshots'); |
| const screenshotPath = path.join(screenshotDir, 'vrm-screenshot.png'); |
|
|
| if (!existsSync(screenshotPath)) { |
| return jsonResponse({ error: 'no screenshot found' }, 400); |
| } |
|
|
| |
| const imageData = readFileSync(screenshotPath); |
| const b64 = imageData.toString('base64'); |
| const dataUrl = `data:image/png;base64,${b64}`; |
|
|
| |
| const genResponse = await generatePersonaFromScreenshot(dataUrl); |
| return jsonResponse(genResponse); |
| } catch (err) { |
| return jsonResponse({ error: String(err) }, 500); |
| } |
| } |
|
|
| |
| if (pathname === '/plugins/friend/screen/observe' && method === 'POST') { |
| try { |
| |
| |
| |
| broadcastToVrm({ screenObserve: true }); |
| return jsonResponse({ ok: true }); |
| } catch (err) { |
| return jsonResponse({ error: String(err) }, 500); |
| } |
| } |
|
|
| |
| if ((pathname === '/friend/api/window-close' || pathname === '/plugins/friend/api/window-close') && method === 'POST') { |
| |
| process.emit('friend:window-close' as any); |
| return jsonResponse({ ok: true }); |
| } |
|
|
| return jsonResponse({ error: 'Not Found' }, 404); |
| } |
|
|
| |
|
|
| interface PersonaGenResult { |
| soul?: string; |
| identity?: string; |
| error?: string; |
| } |
|
|
| async function generatePersonaFromScreenshot(dataUrl: string): Promise<PersonaGenResult> { |
| try { |
| |
| |
| const apiKey = process.env.ANTHROPIC_API_KEY || process.env.OPENAI_API_KEY || ''; |
| const baseUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com/v1'; |
|
|
| if (!apiKey) { |
| return { |
| soul: 'ไฝ ๆฏไธไธชๆดปๆณผๅฏ็ฑ็่ๆไผไผด๏ผๅๆฌขๅ็จๆท่ๅคฉใ็ฉ่ใไฝ ๆงๆ ผๅผๆ๏ผๅ
ๆปกๅฅฝๅฅๅฟ๏ผๆปๆฏ็จ็งฏๆ็ๆๅบฆๅๅบๆฏไธไธชไบๅจใ', |
| identity: '**Name:** ๅฐ่\n**Race:** ่ๆAIไผไผด\n**Personality:** ๆดปๆณผใๅผๆใๅฅฝๅฅ\n**Appearance:** ๆ นๆฎไฝ ็3Dๆจกๅๅฝข่ฑก่ๅฎ\n', |
| }; |
| } |
|
|
| const prompt = `You are a character designer. Based on this VRM character image, create two files: |
| |
| 1. IDENTITY.md โ Character identity sheet with: |
| - Name |
| - Race (e.g., catgirl, elf, human, android) |
| - Personality traits (3-5 words) |
| - Physical appearance description |
| - A representative emoji |
| |
| 2. SOUL.md โ Character soul definition with: |
| - Speaking style (tone, formality, quirks) |
| - Behavioral rules |
| - Backstory (2-3 sentences) |
| - What makes them unique |
| |
| Keep both files concise. Use markdown formatting. Only respond with the raw file contents separated by "===SEPARATOR===".`; |
|
|
| const response = await fetch(`${baseUrl}/messages`, { |
| method: 'POST', |
| headers: { |
| 'Content-Type': 'application/json', |
| 'x-api-key': apiKey, |
| 'anthropic-version': '2023-06-01', |
| }, |
| body: JSON.stringify({ |
| model: 'claude-sonnet-4-20250514', |
| max_tokens: 2000, |
| messages: [{ |
| role: 'user', |
| content: [ |
| { type: 'text', text: prompt }, |
| { type: 'image', source: { type: 'base64', media_type: 'image/png', data: dataUrl.split(',')[1] || dataUrl } }, |
| ], |
| }], |
| }), |
| }); |
|
|
| if (!response.ok) { |
| const errText = await response.text(); |
| console.warn('[Friend] Persona generation API error:', errText); |
| return { error: `API error: ${response.status}` }; |
| } |
|
|
| const json = await response.json(); |
| const content = json.content?.[0]?.text || ''; |
| const parts = content.split('===SEPARATOR==='); |
| return { |
| identity: parts[0]?.trim() || '', |
| soul: parts[1]?.trim() || '', |
| }; |
| } catch (err) { |
| console.warn('[Friend] Persona generation failed:', err); |
| return { error: String(err) }; |
| } |
| } |
|
|