pi-tau-web-server 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +303 -0
- package/bin/auth.js +96 -0
- package/bin/config.js +99 -0
- package/bin/model-utils.js +134 -0
- package/bin/server-main.js +1189 -0
- package/bin/sessions.js +492 -0
- package/bin/tau.js +8 -0
- package/bin/tree.js +248 -0
- package/bin/types.js +2 -0
- package/package.json +74 -0
- package/public/app-main.js +2025 -0
- package/public/app-types.js +1 -0
- package/public/app.js +1 -0
- package/public/command-palette.js +42 -0
- package/public/dialogs.js +199 -0
- package/public/file-browser.js +196 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/favicon-16.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/tau-192.png +0 -0
- package/public/icons/tau-512.png +0 -0
- package/public/icons/tau-logo.png +0 -0
- package/public/icons/tau-logo.svg +13 -0
- package/public/icons/tau-maskable-512.png +0 -0
- package/public/icons/tau-new.png +0 -0
- package/public/index.html +264 -0
- package/public/launcher-panel.js +54 -0
- package/public/launcher.js +84 -0
- package/public/manifest.json +28 -0
- package/public/markdown.js +336 -0
- package/public/message-renderer.js +268 -0
- package/public/model-picker.js +478 -0
- package/public/session-sidebar.js +460 -0
- package/public/session-stats-card.js +123 -0
- package/public/state.js +81 -0
- package/public/style.css +3864 -0
- package/public/sw.js +66 -0
- package/public/themes.js +72 -0
- package/public/tool-card.js +317 -0
- package/public/tree-view.js +474 -0
- package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/public/vendor/katex/katex.min.css +1 -0
- package/public/vendor/katex/katex.min.js +1 -0
- package/public/voice-input.js +74 -0
- package/public/websocket-client.js +156 -0
- package/scripts/copy-katex.mjs +32 -0
- package/src/pi-extension/tau-tree.ts +71 -0
- package/src/public/app-main.ts +2190 -0
- package/src/public/app-types.ts +96 -0
- package/src/public/app.ts +1 -0
- package/src/public/command-palette.ts +53 -0
- package/src/public/dialogs.ts +251 -0
- package/src/public/file-browser.ts +224 -0
- package/src/public/launcher-panel.ts +68 -0
- package/src/public/launcher.ts +101 -0
- package/src/public/legacy-dom.d.ts +29 -0
- package/src/public/markdown.ts +372 -0
- package/src/public/message-renderer.ts +311 -0
- package/src/public/model-picker.ts +500 -0
- package/src/public/session-sidebar.ts +522 -0
- package/src/public/session-stats-card.ts +176 -0
- package/src/public/state.ts +96 -0
- package/src/public/sw.ts +79 -0
- package/src/public/themes.ts +73 -0
- package/src/public/tool-card.ts +375 -0
- package/src/public/tree-view.ts +527 -0
- package/src/public/voice-input.ts +98 -0
- package/src/public/websocket-client.ts +165 -0
- package/src/server/auth.ts +88 -0
- package/src/server/config.ts +88 -0
- package/src/server/model-utils.ts +122 -0
- package/src/server/server-main.ts +1004 -0
- package/src/server/sessions.ts +481 -0
- package/src/server/tau.ts +9 -0
- package/src/server/tree.ts +288 -0
- package/src/server/types.ts +68 -0
- package/tsconfig.json +3 -0
- package/tsconfig.public.json +15 -0
- package/tsconfig.server.json +16 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Client - Handles connection to backend WebSocket server
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export class WebSocketClient extends EventTarget {
|
|
6
|
+
url: string;
|
|
7
|
+
ws: WebSocket | null;
|
|
8
|
+
reconnectAttempts: number;
|
|
9
|
+
maxReconnectAttempts: number;
|
|
10
|
+
reconnectDelay: number;
|
|
11
|
+
maxReconnectDelay: number;
|
|
12
|
+
isIntentionallyClosed: boolean;
|
|
13
|
+
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
|
14
|
+
connectionState: 'idle' | 'connecting' | 'open' | 'closed';
|
|
15
|
+
|
|
16
|
+
constructor(url: string) {
|
|
17
|
+
super();
|
|
18
|
+
this.url = url;
|
|
19
|
+
this.ws = null;
|
|
20
|
+
this.reconnectAttempts = 0;
|
|
21
|
+
this.maxReconnectAttempts = Infinity;
|
|
22
|
+
this.reconnectDelay = 1000;
|
|
23
|
+
this.maxReconnectDelay = 10000;
|
|
24
|
+
this.isIntentionallyClosed = false;
|
|
25
|
+
this.reconnectTimer = null;
|
|
26
|
+
this.connectionState = 'idle';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
connect() {
|
|
30
|
+
if (this.connectionState === 'connecting') return;
|
|
31
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
|
32
|
+
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) return;
|
|
33
|
+
|
|
34
|
+
this.isIntentionallyClosed = false;
|
|
35
|
+
this.connectionState = 'connecting';
|
|
36
|
+
if (this.reconnectTimer) {
|
|
37
|
+
clearTimeout(this.reconnectTimer);
|
|
38
|
+
this.reconnectTimer = null;
|
|
39
|
+
}
|
|
40
|
+
// Close only fully stale sockets before reconnecting
|
|
41
|
+
if (this.ws && (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED)) {
|
|
42
|
+
this.ws = null;
|
|
43
|
+
}
|
|
44
|
+
this.ws = new WebSocket(this.url);
|
|
45
|
+
|
|
46
|
+
this.ws.onopen = () => {
|
|
47
|
+
console.log('[WS] Connected');
|
|
48
|
+
this.reconnectAttempts = 0;
|
|
49
|
+
this.connectionState = 'open';
|
|
50
|
+
this.dispatchEvent(new CustomEvent('connected'));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
this.ws.onmessage = (event) => {
|
|
54
|
+
try {
|
|
55
|
+
const message = JSON.parse(event.data);
|
|
56
|
+
this.handleMessage(message);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('[WS] Failed to parse message:', error);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
this.ws.onerror = (error) => {
|
|
63
|
+
console.error('[WS] Error:', error);
|
|
64
|
+
this.dispatchEvent(new CustomEvent('error', { detail: error }));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
this.ws.onclose = (event) => {
|
|
68
|
+
console.log(`[WS] Disconnected (code=${event.code}, reason=${event.reason || 'n/a'})`);
|
|
69
|
+
this.connectionState = 'closed';
|
|
70
|
+
this.dispatchEvent(new CustomEvent('disconnected'));
|
|
71
|
+
|
|
72
|
+
if (!this.isIntentionallyClosed) {
|
|
73
|
+
this.attemptReconnect();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
disconnect() {
|
|
79
|
+
this.isIntentionallyClosed = true;
|
|
80
|
+
this.connectionState = 'closed';
|
|
81
|
+
if (this.reconnectTimer) {
|
|
82
|
+
clearTimeout(this.reconnectTimer);
|
|
83
|
+
this.reconnectTimer = null;
|
|
84
|
+
}
|
|
85
|
+
if (this.ws) {
|
|
86
|
+
this.ws.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Force reconnect — resets attempt counter and connects fresh
|
|
91
|
+
forceReconnect() {
|
|
92
|
+
this.reconnectAttempts = 0;
|
|
93
|
+
this.isIntentionallyClosed = false;
|
|
94
|
+
this.connectionState = 'closed';
|
|
95
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
96
|
+
try { this.ws.close(1000, 'force reconnect'); } catch (e) {}
|
|
97
|
+
}
|
|
98
|
+
if (this.reconnectTimer) {
|
|
99
|
+
clearTimeout(this.reconnectTimer);
|
|
100
|
+
this.reconnectTimer = null;
|
|
101
|
+
}
|
|
102
|
+
this.connect();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
attemptReconnect() {
|
|
106
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
107
|
+
console.error('[WS] Max reconnection attempts reached');
|
|
108
|
+
this.dispatchEvent(new CustomEvent('reconnectFailed'));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
this.reconnectAttempts++;
|
|
113
|
+
const delay = Math.min(this.maxReconnectDelay, this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1));
|
|
114
|
+
|
|
115
|
+
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
116
|
+
|
|
117
|
+
this.reconnectTimer = setTimeout(() => {
|
|
118
|
+
this.reconnectTimer = null;
|
|
119
|
+
this.connect();
|
|
120
|
+
}, delay);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
send(data: unknown) {
|
|
124
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
125
|
+
this.ws.send(JSON.stringify(data));
|
|
126
|
+
} else {
|
|
127
|
+
console.error('[WS] Cannot send, not connected');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
handleMessage(message: { type?: string; [key: string]: unknown }) {
|
|
132
|
+
// Emit events based on message type
|
|
133
|
+
switch (message.type) {
|
|
134
|
+
case 'event':
|
|
135
|
+
this.dispatchEvent(new CustomEvent('rpcEvent', { detail: { sessionId: message.sessionId, event: message.event } }));
|
|
136
|
+
break;
|
|
137
|
+
case 'state':
|
|
138
|
+
this.dispatchEvent(new CustomEvent('stateUpdate', { detail: message }));
|
|
139
|
+
break;
|
|
140
|
+
case 'error':
|
|
141
|
+
this.dispatchEvent(new CustomEvent('serverError', { detail: message }));
|
|
142
|
+
break;
|
|
143
|
+
case 'session_switch':
|
|
144
|
+
this.dispatchEvent(new CustomEvent('sessionSwitch'));
|
|
145
|
+
break;
|
|
146
|
+
case 'live_session_snapshot':
|
|
147
|
+
this.dispatchEvent(new CustomEvent('liveSessionSnapshot', { detail: message }));
|
|
148
|
+
break;
|
|
149
|
+
case 'response':
|
|
150
|
+
this.dispatchEvent(new CustomEvent('rpcResponse', { detail: message }));
|
|
151
|
+
break;
|
|
152
|
+
case 'live_session_created':
|
|
153
|
+
this.dispatchEvent(new CustomEvent('liveSessionCreated', { detail: message.session }));
|
|
154
|
+
break;
|
|
155
|
+
case 'live_session_updated':
|
|
156
|
+
this.dispatchEvent(new CustomEvent('liveSessionUpdated', { detail: message.session }));
|
|
157
|
+
break;
|
|
158
|
+
case 'live_session_closed':
|
|
159
|
+
this.dispatchEvent(new CustomEvent('liveSessionClosed', { detail: message }));
|
|
160
|
+
break;
|
|
161
|
+
default:
|
|
162
|
+
console.warn('[WS] Unknown message type:', message.type);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Session-cookie auth on top of HTTP Basic.
|
|
3
|
+
*
|
|
4
|
+
* iOS Safari (and the installed PWA) evicts cached Basic credentials when the
|
|
5
|
+
* user switches apps, so Basic alone re-prompts on every return. After a
|
|
6
|
+
* successful Basic request the server mints a signed, stateless session token
|
|
7
|
+
* carried in a browser-session cookie (no Max-Age): it survives app switching
|
|
8
|
+
* but ends with the browser session, and the embedded expiry bounds its life
|
|
9
|
+
* even if the browser restores session cookies with restored tabs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require('node:crypto');
|
|
13
|
+
|
|
14
|
+
import { TAU_SETTINGS, saveTauSetting } from './config.js';
|
|
15
|
+
|
|
16
|
+
export const SESSION_COOKIE_NAME = 'tau_session';
|
|
17
|
+
export const SESSION_TTL_SECONDS = 12 * 3600;
|
|
18
|
+
export const SESSION_REFRESH_THRESHOLD_SECONDS = 6 * 3600;
|
|
19
|
+
|
|
20
|
+
let cachedSecret = '';
|
|
21
|
+
|
|
22
|
+
// Lazy so installs that never enable auth never write settings.json.
|
|
23
|
+
function getCookieSecret(): string {
|
|
24
|
+
if (cachedSecret) return cachedSecret;
|
|
25
|
+
if (TAU_SETTINGS.cookieSecret) {
|
|
26
|
+
cachedSecret = TAU_SETTINGS.cookieSecret;
|
|
27
|
+
return cachedSecret;
|
|
28
|
+
}
|
|
29
|
+
cachedSecret = crypto.randomBytes(32).toString('hex');
|
|
30
|
+
if (!saveTauSetting('cookieSecret', cachedSecret)) {
|
|
31
|
+
console.warn('tau: could not persist tau.cookieSecret to settings.json; session cookies will not survive a server restart (every restart will log all devices out). Set TAU_COOKIE_SECRET to work around this.');
|
|
32
|
+
}
|
|
33
|
+
return cachedSecret;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Recomputed per call so tokens die when the credentials change. The newline
|
|
37
|
+
// separator cannot appear in either field, unlike ':' which passwords allow.
|
|
38
|
+
function credentialFingerprint(): string {
|
|
39
|
+
return crypto.createHash('sha256').update(TAU_SETTINGS.user + '\n' + TAU_SETTINGS.pass).digest('hex');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function signToken(expiresAtSeconds: number): string {
|
|
43
|
+
return crypto.createHmac('sha256', getCookieSecret())
|
|
44
|
+
.update('v1.' + expiresAtSeconds + '.' + credentialFingerprint())
|
|
45
|
+
.digest('base64url');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function issueSessionToken(expiresAtSeconds?: number): string {
|
|
49
|
+
const expires = expiresAtSeconds ?? Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;
|
|
50
|
+
return 'v1.' + expires + '.' + signToken(expires);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function verifySessionToken(token: string): { valid: boolean; expiresAt: number } {
|
|
54
|
+
const invalid = { valid: false, expiresAt: 0 };
|
|
55
|
+
try {
|
|
56
|
+
const parts = String(token).split('.');
|
|
57
|
+
if (parts.length !== 3 || parts[0] !== 'v1') return invalid;
|
|
58
|
+
const expiresAt = parseInt(parts[1], 10);
|
|
59
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) return invalid;
|
|
60
|
+
const expected = Buffer.from(signToken(expiresAt));
|
|
61
|
+
const actual = Buffer.from(parts[2]);
|
|
62
|
+
if (expected.length !== actual.length || !crypto.timingSafeEqual(expected, actual)) return invalid;
|
|
63
|
+
return { valid: true, expiresAt };
|
|
64
|
+
} catch {
|
|
65
|
+
return invalid;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Manual parser because the WebSocket upgrade path has no framework.
|
|
70
|
+
export function parseCookies(header: string | undefined): Record<string, string> {
|
|
71
|
+
const out: Record<string, string> = {};
|
|
72
|
+
if (!header) return out;
|
|
73
|
+
for (const part of header.split(';')) {
|
|
74
|
+
const eq = part.indexOf('=');
|
|
75
|
+
if (eq === -1) continue;
|
|
76
|
+
const name = part.slice(0, eq).trim();
|
|
77
|
+
if (!name) continue;
|
|
78
|
+
try { out[name] = decodeURIComponent(part.slice(eq + 1).trim()); } catch {}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// No Max-Age/Expires on purpose: a browser-session cookie is dropped when the
|
|
84
|
+
// browser session ends, so killing the browser re-prompts for Basic auth.
|
|
85
|
+
export function buildSessionCookie(token: string, opts: { secure: boolean; clear?: boolean }): string {
|
|
86
|
+
if (opts.clear) return `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
87
|
+
return `${SESSION_COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Lax` + (opts.secure ? '; Secure' : '');
|
|
88
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
|
|
5
|
+
import type { TauArgs, TauSettings, TauSettingsFile } from './types.js';
|
|
6
|
+
|
|
7
|
+
export function parseArgs(argv: string[]): TauArgs {
|
|
8
|
+
const out: TauArgs = {};
|
|
9
|
+
for (let i = 0; i < argv.length; i++) {
|
|
10
|
+
const arg = argv[i];
|
|
11
|
+
if (!arg.startsWith('--')) continue;
|
|
12
|
+
const key = arg.slice(2);
|
|
13
|
+
if (key === 'open') { out.open = true; continue; }
|
|
14
|
+
const next = argv[i + 1];
|
|
15
|
+
if (next && !next.startsWith('--')) { out[key] = next; i++; }
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const ARGS = parseArgs(process.argv.slice(2));
|
|
21
|
+
export const USER_HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
22
|
+
export const PI_AGENT_DIR = process.env.PI_CODING_AGENT_DIR || path.join(USER_HOME, '.pi', 'agent');
|
|
23
|
+
export const SESSIONS_DIR = process.env.PI_CODING_AGENT_SESSION_DIR || path.join(PI_AGENT_DIR, 'sessions');
|
|
24
|
+
|
|
25
|
+
export function expandHome(p: string) {
|
|
26
|
+
if (!p || typeof p !== 'string') return p;
|
|
27
|
+
return p.startsWith('~') ? path.join(USER_HOME, p.slice(1)) : p;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function loadTauSettings(): TauSettings {
|
|
31
|
+
let settings: TauSettingsFile['tau'] = {};
|
|
32
|
+
try {
|
|
33
|
+
const settingsPath = path.join(PI_AGENT_DIR, 'settings.json');
|
|
34
|
+
settings = ((JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as TauSettingsFile).tau || {});
|
|
35
|
+
} catch {}
|
|
36
|
+
return {
|
|
37
|
+
port: parseInt(String(ARGS.port || process.env.TAU_PORT || settings.port || '3001'), 10),
|
|
38
|
+
host: ARGS.host || process.env.TAU_HOST || settings.host || '0.0.0.0',
|
|
39
|
+
user: process.env.TAU_USER || settings.user || '',
|
|
40
|
+
pass: process.env.TAU_PASS || settings.pass || '',
|
|
41
|
+
authEnabled: settings.authEnabled,
|
|
42
|
+
cookieSecret: process.env.TAU_COOKIE_SECRET || settings.cookieSecret || '',
|
|
43
|
+
projectsDir: expandHome(ARGS['projects-dir'] || process.env.TAU_PROJECTS_DIR || settings.projectsDir || ''),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const TAU_SETTINGS = loadTauSettings();
|
|
48
|
+
export const AUTH_CONFIGURED = !!(TAU_SETTINGS.user && TAU_SETTINGS.pass);
|
|
49
|
+
export const PORT = TAU_SETTINGS.port;
|
|
50
|
+
export const HOST = TAU_SETTINGS.host;
|
|
51
|
+
export const STATIC_DIR = process.env.TAU_STATIC_DIR || findPublicDir();
|
|
52
|
+
|
|
53
|
+
function findPublicDir() {
|
|
54
|
+
const candidates: string[] = [];
|
|
55
|
+
const add = (p: string) => candidates.push(path.resolve(p));
|
|
56
|
+
add(path.join(__dirname, '..', 'public'));
|
|
57
|
+
add(path.join(process.cwd(), 'public'));
|
|
58
|
+
try {
|
|
59
|
+
const pkgPath = require.resolve('pi-tau-web-server/package.json');
|
|
60
|
+
add(path.join(path.dirname(pkgPath), 'public'));
|
|
61
|
+
} catch {}
|
|
62
|
+
add(path.join(process.cwd(), 'node_modules', 'pi-tau-web-server', 'public'));
|
|
63
|
+
return candidates.find((c) => fs.existsSync(path.join(c, 'index.html'))) || candidates[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const MIME_TYPES = {
|
|
67
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
68
|
+
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
|
69
|
+
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
70
|
+
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff',
|
|
71
|
+
'.woff2': 'font/woff2',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export function saveTauSetting(key: string, value: unknown): boolean {
|
|
75
|
+
const settingsPath = path.join(PI_AGENT_DIR, 'settings.json');
|
|
76
|
+
try {
|
|
77
|
+
let settings: TauSettingsFile = {};
|
|
78
|
+
try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as TauSettingsFile; } catch {}
|
|
79
|
+
if (!settings.tau) settings.tau = {};
|
|
80
|
+
settings.tau[key] = value;
|
|
81
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
82
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const { execFile } = require('node:child_process');
|
|
2
|
+
|
|
3
|
+
import type { JsonRecord, ModelIdentity, ParsedModelSpec, StatusError } from './types.js';
|
|
4
|
+
|
|
5
|
+
type ExecFileCallback = (err: NodeJS.ErrnoException | null, stdout: string, stderr: string) => void;
|
|
6
|
+
type ExecFileFn = (file: string, args: string[], opts: JsonRecord, callback: ExecFileCallback) => void;
|
|
7
|
+
|
|
8
|
+
export function modelLabel(model: ModelIdentity | string | null | undefined, fallback = '') {
|
|
9
|
+
if (!model) return fallback || '';
|
|
10
|
+
if (typeof model === 'string') return model;
|
|
11
|
+
if (model.provider && model.id) return `${model.provider}/${model.id}`;
|
|
12
|
+
return model.id || model.name || fallback || '';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Normalize any model value into the canonical form: null or a full
|
|
16
|
+
// {provider, id, ...} object. Bare `provider/id` strings (and "id" strings
|
|
17
|
+
// with no slash) are parsed into objects; anything unrecognizable becomes null.
|
|
18
|
+
export function normalizeModel(value: unknown): ModelIdentity | null {
|
|
19
|
+
if (!value) return null;
|
|
20
|
+
if (typeof value === 'object') {
|
|
21
|
+
const record = value as ModelIdentity;
|
|
22
|
+
if (record.provider && record.id) return { ...record };
|
|
23
|
+
if (record.id) return { ...record, provider: record.provider || '' };
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === 'string') {
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
if (!trimmed) return null;
|
|
29
|
+
const slashIdx = trimmed.indexOf('/');
|
|
30
|
+
if (slashIdx === -1) return { provider: '', id: trimmed };
|
|
31
|
+
const provider = trimmed.slice(0, slashIdx);
|
|
32
|
+
const id = trimmed.slice(slashIdx + 1);
|
|
33
|
+
if (!id) return null;
|
|
34
|
+
return { provider, id };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Parse a `provider/id[:level]` spec string (as passed on session creation or
|
|
40
|
+
// the model-input box) into a canonical {provider, id} object plus an optional
|
|
41
|
+
// thinking level. Returns {model, level} where `model` is null when unparseable.
|
|
42
|
+
export function parseModelSpecToModel(spec: unknown): ParsedModelSpec {
|
|
43
|
+
const trimmed = String(spec || '').trim();
|
|
44
|
+
if (!trimmed) return { model: null, level: null };
|
|
45
|
+
let level = null;
|
|
46
|
+
const colonIdx = trimmed.lastIndexOf(':');
|
|
47
|
+
if (colonIdx !== -1) {
|
|
48
|
+
const candidate = trimmed.slice(colonIdx + 1).toLowerCase();
|
|
49
|
+
if (['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'].includes(candidate)) {
|
|
50
|
+
level = candidate;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const core = (colonIdx !== -1 && level) ? trimmed.slice(0, colonIdx) : trimmed;
|
|
54
|
+
return { model: normalizeModel(core), level };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseYesNo(value: unknown) {
|
|
58
|
+
const normalized = String(value || '').trim().toLowerCase();
|
|
59
|
+
if (normalized === 'yes') return true;
|
|
60
|
+
if (normalized === 'no') return false;
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parsePiListModels(output: string) {
|
|
65
|
+
const lines = String(output || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
66
|
+
const models = [];
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
if (/^provider\s+model\s+/i.test(line)) continue;
|
|
69
|
+
const parts = line.split(/\s{2,}/).map((part) => part.trim()).filter(Boolean);
|
|
70
|
+
if (parts.length < 2) continue;
|
|
71
|
+
const [provider, id, context, maxOutput, thinking, images] = parts;
|
|
72
|
+
if (!provider || !id) continue;
|
|
73
|
+
models.push({
|
|
74
|
+
provider,
|
|
75
|
+
id,
|
|
76
|
+
...(context !== undefined ? { context } : {}),
|
|
77
|
+
...(maxOutput !== undefined ? { maxOutput } : {}),
|
|
78
|
+
...(thinking !== undefined ? { thinking: parseYesNo(thinking) } : {}),
|
|
79
|
+
...(images !== undefined ? { images: parseYesNo(images) } : {}),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return models;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const MODEL_LIST_CACHE_MS = 5 * 60 * 1000;
|
|
86
|
+
let modelListCache: { at: number; models: ModelIdentity[] } = { at: 0, models: [] };
|
|
87
|
+
let _execFileForTest: ExecFileFn | null = null;
|
|
88
|
+
|
|
89
|
+
export function execFileAsync(file: string, args: string[], opts: JsonRecord): Promise<{ stdout: string; stderr: string }> {
|
|
90
|
+
const runner: ExecFileFn = _execFileForTest || execFile;
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
runner(file, args, opts, (err: NodeJS.ErrnoException | null, stdout: string, stderr: string) => {
|
|
93
|
+
if (err) {
|
|
94
|
+
(err as StatusError).stderr = stderr;
|
|
95
|
+
reject(err);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
resolve({ stdout, stderr });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function getAvailableModels() {
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (modelListCache.at && now - modelListCache.at < MODEL_LIST_CACHE_MS) {
|
|
106
|
+
return modelListCache.models;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const { stdout } = await execFileAsync('pi', ['--list-models'], { timeout: 30000, encoding: 'utf8' });
|
|
110
|
+
const models = parsePiListModels(stdout);
|
|
111
|
+
modelListCache = { at: now, models };
|
|
112
|
+
return models;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
console.warn('[Tau] Failed to list Pi models:', err instanceof Error ? err.message : err);
|
|
115
|
+
modelListCache = { at: now, models: modelListCache.models || [] };
|
|
116
|
+
return modelListCache.models;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
export function _setExecFileForTest(fn: ExecFileFn | null | undefined) { _execFileForTest = fn || null; modelListCache = { at: 0, models: [] }; }
|
|
122
|
+
export function _clearModelListCacheForTest() { modelListCache = { at: 0, models: [] }; }
|