chenbhao commited on
Commit
46c0fae
·
1 Parent(s): e18c81d

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
- function logger() {
21
- return {
22
- info: (msg: string) => console.log(`[Friend] ${msg}`),
23
- warn: (msg: string) => console.warn(`[Friend] ${msg}`),
24
- }
25
- }
26
 
27
  type Page = 'status' | 'help'
28
 
@@ -44,7 +41,7 @@ export async function call(
44
  </Box>
45
  )
46
  }
47
- launchTauri(FRIEND_FRONTEND_DIR, logger())
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
- .run(tauri::generate_context!())
 
 
 
 
 
 
 
 
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
- * Builds and launches the VRM desktop pet as a native Tauri window
5
- * (transparent, always-on-top) instead of a browser tab.
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
- * Launch the Friend Tauri desktop window.
15
- * Uses the release binary (built via `npx tauri build`).
16
- * The binary loads the pre-built frontend from frontend/dist/.
17
- */
18
- export function launchTauri(appDir: string, log: { info: (msg: string) => void; warn: (msg: string) => void }) {
19
- const releaseBinary = path.join(appDir, 'src-tauri', 'target', 'release', 'versperclaw-friend')
20
- const debugBinary = path.join(appDir, 'src-tauri', 'target', 'debug', 'versperclaw-friend')
21
-
22
- const binary = existsSync(releaseBinary)
23
- ? releaseBinary
24
- : existsSync(debugBinary)
25
- ? debugBinary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  : null
27
 
28
  if (!binary) {
29
- log.warn(`Friend: Tauri binary not found. Run \`cd ${appDir} && npx tauri build\` first.`)
30
- log.warn(`Looking for: ${releaseBinary}`)
31
  return
32
  }
33
 
34
- log.info(`Starting Friend desktop window from ${binary}`)
 
35
 
36
  tauriProcess = spawn(binary, [], {
37
- cwd: appDir,
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('Stopping Friend Tauri window...')
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(friendFrontendDir, {
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
- 'Enable VRM desktop companion modeopens a 3D avatar window and adds avatar awareness, emotions, and screen observation to the conversation.',
32
- userInvocable: true,
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: `[VRM avatar frontend launched at ${FRIEND_URL}. Tell the user the browser window is open and ready!]\n\n${buildVrmSystemPrompt()}`,
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
  },