feat: /friend start tauri app
Browse files
src/commands/friend/friend.tsx
CHANGED
|
@@ -14,15 +14,12 @@ import {
|
|
| 14 |
} from '../../friend/tauri-launcher.js'
|
| 15 |
import type { LocalJSXCommandOnDone, CommandResultDisplay } from '../../types/command.js'
|
| 16 |
|
| 17 |
-
const FRIEND_FRONTEND_DIR = '../../components/friend/frontend'
|
| 18 |
const FRIEND_URL = 'http://127.0.0.1:3456/friend/'
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
}
|
| 25 |
-
}
|
| 26 |
|
| 27 |
type Page = 'status' | 'help'
|
| 28 |
|
|
@@ -44,7 +41,7 @@ export async function call(
|
|
| 44 |
</Box>
|
| 45 |
)
|
| 46 |
}
|
| 47 |
-
launchTauri(
|
| 48 |
return (
|
| 49 |
<Box flexDirection="column">
|
| 50 |
<Text>Starting Friend VRM companion...</Text>
|
|
|
|
| 14 |
} from '../../friend/tauri-launcher.js'
|
| 15 |
import type { LocalJSXCommandOnDone, CommandResultDisplay } from '../../types/command.js'
|
| 16 |
|
|
|
|
| 17 |
const FRIEND_URL = 'http://127.0.0.1:3456/friend/'
|
| 18 |
|
| 19 |
+
const logger = () => ({
|
| 20 |
+
info: (msg: string) => console.log(`[Friend] ${msg}`),
|
| 21 |
+
warn: (msg: string) => console.warn(`[Friend] ${msg}`),
|
| 22 |
+
})
|
|
|
|
|
|
|
| 23 |
|
| 24 |
type Page = 'status' | 'help'
|
| 25 |
|
|
|
|
| 41 |
</Box>
|
| 42 |
)
|
| 43 |
}
|
| 44 |
+
await launchTauri(logger())
|
| 45 |
return (
|
| 46 |
<Box flexDirection="column">
|
| 47 |
<Text>Starting Friend VRM companion...</Text>
|
src/components/friend/frontend/src-tauri/src/lib.rs
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
pub fn run() {
|
| 2 |
-
tauri::Builder::default()
|
| 3 |
.plugin(tauri_plugin_opener::init())
|
| 4 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
.expect("error while running tauri application");
|
| 6 |
-
}
|
|
|
|
| 1 |
+
use tauri::Manager;
|
| 2 |
+
|
| 3 |
+
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
| 4 |
pub fn run() {
|
| 5 |
+
let builder = tauri::Builder::default()
|
| 6 |
.plugin(tauri_plugin_opener::init())
|
| 7 |
+
.setup(|app| {
|
| 8 |
+
let window = app.get_webview_window("main").unwrap();
|
| 9 |
+
// Force load from HTTP server so WebGL works properly in webkit2gtk
|
| 10 |
+
window.eval("window.location.replace('http://127.0.0.1:3456/friend/')")
|
| 11 |
+
.map_err(|e| eprintln!("Failed to set URL: {e}")).ok();
|
| 12 |
+
Ok(())
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
builder.run(tauri::generate_context!())
|
| 16 |
.expect("error while running tauri application");
|
| 17 |
+
}
|
src/friend/tauri-launcher.ts
CHANGED
|
@@ -1,80 +1,150 @@
|
|
| 1 |
/**
|
| 2 |
* Tauri desktop app process management for Friend (VersperClaw native).
|
| 3 |
*
|
| 4 |
-
*
|
| 5 |
-
*
|
| 6 |
*/
|
| 7 |
-
import { spawn } from 'node:child_process';
|
| 8 |
import path from 'node:path';
|
| 9 |
import { existsSync } from 'node:fs';
|
| 10 |
|
| 11 |
let tauriProcess: ReturnType<typeof spawn> | null = null;
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
: null
|
| 27 |
|
| 28 |
if (!binary) {
|
| 29 |
-
log.warn(`Friend
|
| 30 |
-
log.warn(`
|
| 31 |
return
|
| 32 |
}
|
| 33 |
|
| 34 |
-
|
|
|
|
| 35 |
|
| 36 |
tauriProcess = spawn(binary, [], {
|
| 37 |
-
cwd:
|
| 38 |
stdio: 'pipe',
|
| 39 |
detached: true,
|
| 40 |
})
|
| 41 |
|
| 42 |
tauriProcess.stdout?.on('data', (data: Buffer) => {
|
| 43 |
for (const line of data.toString().split('\n').filter(Boolean)) {
|
| 44 |
-
log.info(line)
|
| 45 |
}
|
| 46 |
})
|
| 47 |
|
| 48 |
tauriProcess.stderr?.on('data', (data: Buffer) => {
|
| 49 |
for (const line of data.toString().split('\n').filter(Boolean)) {
|
| 50 |
-
log.info(line)
|
| 51 |
}
|
| 52 |
})
|
| 53 |
|
| 54 |
tauriProcess.on('error', (err: Error) => {
|
| 55 |
-
log.warn(`Friend Tauri error: ${err.message}`)
|
| 56 |
tauriProcess = null
|
| 57 |
})
|
| 58 |
|
| 59 |
tauriProcess.on('exit', (code: number | null) => {
|
| 60 |
-
log.info(`Friend Tauri exited (code: ${code})`)
|
| 61 |
tauriProcess = null
|
| 62 |
})
|
| 63 |
}
|
| 64 |
|
| 65 |
export function stopTauri(log: { info: (msg: string) => void }) {
|
| 66 |
if (tauriProcess) {
|
| 67 |
-
log.info('
|
| 68 |
const proc = tauriProcess
|
| 69 |
tauriProcess = null
|
| 70 |
-
|
| 71 |
proc.kill('SIGTERM')
|
| 72 |
setTimeout(() => {
|
| 73 |
try { if (!proc.killed) proc.kill('SIGKILL') } catch { /* ignore */ }
|
| 74 |
}, 3000)
|
| 75 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
|
| 78 |
export function getTauriProcess(): ReturnType<typeof spawn> | null {
|
| 79 |
return tauriProcess
|
| 80 |
-
}
|
|
|
|
| 1 |
/**
|
| 2 |
* Tauri desktop app process management for Friend (VersperClaw native).
|
| 3 |
*
|
| 4 |
+
* Launches the VRM desktop pet as a native Tauri window.
|
| 5 |
+
* Also manages a background server on port 3456 that serves the frontend.
|
| 6 |
*/
|
| 7 |
+
import { spawn, execSync } from 'node:child_process';
|
| 8 |
import path from 'node:path';
|
| 9 |
import { existsSync } from 'node:fs';
|
| 10 |
|
| 11 |
let tauriProcess: ReturnType<typeof spawn> | null = null;
|
| 12 |
+
let serverProcess: ReturnType<typeof spawn> | null = null;
|
| 13 |
|
| 14 |
+
const SERVER_PORT = 3456
|
| 15 |
+
const FRIEND_URL = `http://127.0.0.1:${SERVER_PORT}/friend/`
|
| 16 |
+
|
| 17 |
+
/** Check if the friend server is already listening. */
|
| 18 |
+
function isServerRunning(): boolean {
|
| 19 |
+
try {
|
| 20 |
+
const result = execSync(
|
| 21 |
+
`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${SERVER_PORT}/friend/ 2>/dev/null || true`,
|
| 22 |
+
{ timeout: 3000, encoding: 'utf-8' },
|
| 23 |
+
)
|
| 24 |
+
return result.trim() === '200'
|
| 25 |
+
} catch {
|
| 26 |
+
return false
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** Wait until the server responds, up to `timeoutMs`. */
|
| 31 |
+
function waitForServer(timeoutMs = 10_000): Promise<boolean> {
|
| 32 |
+
const start = Date.now()
|
| 33 |
+
return new Promise((resolve) => {
|
| 34 |
+
const check = () => {
|
| 35 |
+
if (isServerRunning()) return resolve(true)
|
| 36 |
+
if (Date.now() - start > timeoutMs) return resolve(false)
|
| 37 |
+
setTimeout(check, 500)
|
| 38 |
+
}
|
| 39 |
+
check()
|
| 40 |
+
})
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/** Start the backend server if not already running. */
|
| 44 |
+
async function ensureServer(log: { info: (msg: string) => void; warn: (msg: string) => void }) {
|
| 45 |
+
if (isServerRunning()) {
|
| 46 |
+
log.info('[Friend] Server already running')
|
| 47 |
+
return true
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
const cwd = process.cwd()
|
| 51 |
+
const serverEntry = path.join(cwd, 'src', 'server', 'index.ts')
|
| 52 |
+
if (!existsSync(serverEntry)) {
|
| 53 |
+
log.warn(`[Friend] Server entry not found: ${serverEntry}`)
|
| 54 |
+
return false
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
log.info('[Friend] Starting background server...')
|
| 58 |
+
serverProcess = spawn('bun', ['run', serverEntry, `--port=${SERVER_PORT}`], {
|
| 59 |
+
cwd,
|
| 60 |
+
stdio: 'pipe',
|
| 61 |
+
detached: true,
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
serverProcess.stderr?.on('data', (data: Buffer) => {
|
| 65 |
+
for (const line of data.toString().split('\n').filter(Boolean)) {
|
| 66 |
+
log.info(`[Friend:server] ${line}`)
|
| 67 |
+
}
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
const ok = await waitForServer()
|
| 71 |
+
if (!ok) log.warn('[Friend] Server did not become ready in time')
|
| 72 |
+
return ok
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export async function launchTauri(log: { info: (msg: string) => void; warn: (msg: string) => void }) {
|
| 76 |
+
// 1. Ensure the server is running (Tauri loads from HTTP)
|
| 77 |
+
const serverOk = await ensureServer(log)
|
| 78 |
+
if (!serverOk) {
|
| 79 |
+
log.warn('[Friend] Cannot start — server failed to start')
|
| 80 |
+
return
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// 2. Find the Tauri binary
|
| 84 |
+
const cwd = process.cwd()
|
| 85 |
+
const releaseBinary = path.join(cwd, 'src', 'components', 'friend', 'frontend', 'src-tauri', 'target', 'release', 'versperclaw-friend')
|
| 86 |
+
const debugBinary = path.join(cwd, 'src', 'components', 'friend', 'frontend', 'src-tauri', 'target', 'debug', 'versperclaw-friend')
|
| 87 |
+
|
| 88 |
+
const binary = existsSync(releaseBinary) ? releaseBinary
|
| 89 |
+
: existsSync(debugBinary) ? debugBinary
|
| 90 |
: null
|
| 91 |
|
| 92 |
if (!binary) {
|
| 93 |
+
log.warn(`[Friend] Tauri binary not found. Run \`cd src/components/friend/frontend && npx tauri build\` first.`)
|
| 94 |
+
log.warn(`[Friend] Looked for: ${releaseBinary}`)
|
| 95 |
return
|
| 96 |
}
|
| 97 |
|
| 98 |
+
// 3. Launch the Tauri desktop window (it connects to FRIEND_URL via lib.rs)
|
| 99 |
+
log.info(`[Friend] Starting desktop window from ${binary}`)
|
| 100 |
|
| 101 |
tauriProcess = spawn(binary, [], {
|
| 102 |
+
cwd: path.dirname(binary),
|
| 103 |
stdio: 'pipe',
|
| 104 |
detached: true,
|
| 105 |
})
|
| 106 |
|
| 107 |
tauriProcess.stdout?.on('data', (data: Buffer) => {
|
| 108 |
for (const line of data.toString().split('\n').filter(Boolean)) {
|
| 109 |
+
log.info(`[Friend:tauri] ${line}`)
|
| 110 |
}
|
| 111 |
})
|
| 112 |
|
| 113 |
tauriProcess.stderr?.on('data', (data: Buffer) => {
|
| 114 |
for (const line of data.toString().split('\n').filter(Boolean)) {
|
| 115 |
+
log.info(`[Friend:tauri] ${line}`)
|
| 116 |
}
|
| 117 |
})
|
| 118 |
|
| 119 |
tauriProcess.on('error', (err: Error) => {
|
| 120 |
+
log.warn(`[Friend] Tauri error: ${err.message}`)
|
| 121 |
tauriProcess = null
|
| 122 |
})
|
| 123 |
|
| 124 |
tauriProcess.on('exit', (code: number | null) => {
|
| 125 |
+
log.info(`[Friend] Tauri exited (code: ${code})`)
|
| 126 |
tauriProcess = null
|
| 127 |
})
|
| 128 |
}
|
| 129 |
|
| 130 |
export function stopTauri(log: { info: (msg: string) => void }) {
|
| 131 |
if (tauriProcess) {
|
| 132 |
+
log.info('[Friend] Stopping Tauri window...')
|
| 133 |
const proc = tauriProcess
|
| 134 |
tauriProcess = null
|
|
|
|
| 135 |
proc.kill('SIGTERM')
|
| 136 |
setTimeout(() => {
|
| 137 |
try { if (!proc.killed) proc.kill('SIGKILL') } catch { /* ignore */ }
|
| 138 |
}, 3000)
|
| 139 |
}
|
| 140 |
+
if (serverProcess) {
|
| 141 |
+
log.info('[Friend] Stopping background server...')
|
| 142 |
+
const proc = serverProcess
|
| 143 |
+
serverProcess = null
|
| 144 |
+
proc.kill('SIGTERM')
|
| 145 |
+
}
|
| 146 |
}
|
| 147 |
|
| 148 |
export function getTauriProcess(): ReturnType<typeof spawn> | null {
|
| 149 |
return tauriProcess
|
| 150 |
+
}
|
src/server/index.ts
CHANGED
|
@@ -440,7 +440,7 @@ export function startServer(port = PORT, host = HOST) {
|
|
| 440 |
// Try launching Tauri desktop window first
|
| 441 |
const _srcDir = path.dirname(fileURLToPath(import.meta.url))
|
| 442 |
const friendFrontendDir = path.resolve(_srcDir, '..', '..', 'src', 'components', 'friend', 'frontend')
|
| 443 |
-
launchTauri(
|
| 444 |
info: (msg: string) => console.log(`[Friend] ${msg}`),
|
| 445 |
warn: (msg: string) => console.warn(`[Friend] ${msg}`),
|
| 446 |
})
|
|
|
|
| 440 |
// Try launching Tauri desktop window first
|
| 441 |
const _srcDir = path.dirname(fileURLToPath(import.meta.url))
|
| 442 |
const friendFrontendDir = path.resolve(_srcDir, '..', '..', 'src', 'components', 'friend', 'frontend')
|
| 443 |
+
launchTauri({
|
| 444 |
info: (msg: string) => console.log(`[Friend] ${msg}`),
|
| 445 |
warn: (msg: string) => console.warn(`[Friend] ${msg}`),
|
| 446 |
})
|
src/skills/bundled/friendPrompt.ts
CHANGED
|
@@ -26,20 +26,16 @@ function buildVrmSystemPrompt(): string {
|
|
| 26 |
|
| 27 |
export function registerFriendPromptSkill(): void {
|
| 28 |
registerBundledSkill({
|
| 29 |
-
name: 'friend',
|
| 30 |
description:
|
| 31 |
-
'
|
| 32 |
-
userInvocable:
|
| 33 |
isEnabled: () => getPrefs().enabled ?? false,
|
| 34 |
async getPromptForCommand() {
|
| 35 |
-
// Enable friend prefs when the skill is invoked
|
| 36 |
-
const prefs = updatePrefs({ enabled: true })
|
| 37 |
-
const moodIndex = (prefs as any)._moodIndex ?? 60
|
| 38 |
-
|
| 39 |
return [
|
| 40 |
{
|
| 41 |
type: 'text' as const,
|
| 42 |
-
text:
|
| 43 |
},
|
| 44 |
]
|
| 45 |
},
|
|
|
|
| 26 |
|
| 27 |
export function registerFriendPromptSkill(): void {
|
| 28 |
registerBundledSkill({
|
| 29 |
+
name: 'friend-vrm',
|
| 30 |
description:
|
| 31 |
+
'Add VRM avatar context to the conversation — system prompt for avatar emotions and companion behavior. Called automatically when Friend is enabled.',
|
| 32 |
+
userInvocable: false,
|
| 33 |
isEnabled: () => getPrefs().enabled ?? false,
|
| 34 |
async getPromptForCommand() {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
return [
|
| 36 |
{
|
| 37 |
type: 'text' as const,
|
| 38 |
+
text: buildVrmSystemPrompt(),
|
| 39 |
},
|
| 40 |
]
|
| 41 |
},
|