@nastechai/agent 0.16.0 → 0.17.0
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/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare global {
|
|
2
|
+
interface Window {
|
|
3
|
+
/** Set true by the server only for `nastech dashboard --tui` (or NASTECH_DASHBOARD_TUI=1). */
|
|
4
|
+
__NASTECH_DASHBOARD_EMBEDDED_CHAT__?: boolean;
|
|
5
|
+
/** @deprecated Older injected name; treated as on when true. */
|
|
6
|
+
__NASTECH_DASHBOARD_TUI__?: boolean;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** True only when the dashboard was started with embedded TUI Chat (`nastech dashboard --tui`). */
|
|
11
|
+
export function isDashboardEmbeddedChatEnabled(): boolean {
|
|
12
|
+
if (typeof window === "undefined") return false;
|
|
13
|
+
if (window.__NASTECH_DASHBOARD_EMBEDDED_CHAT__ === true) return true;
|
|
14
|
+
return window.__NASTECH_DASHBOARD_TUI__ === true;
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a token count as a human-readable string (e.g. 1M, 128K, 4096).
|
|
3
|
+
* Strips trailing ".0" for clean round numbers.
|
|
4
|
+
*/
|
|
5
|
+
export function formatTokenCount(n: number): string {
|
|
6
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
|
|
7
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
|
|
8
|
+
return String(n);
|
|
9
|
+
}
|
package/src/lib/fuzzy.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Lightweight fuzzy subsequence scorer for picker filtering.
|
|
2
|
+
//
|
|
3
|
+
// Matches a query as an ordered subsequence of the target (so `g4o` matches
|
|
4
|
+
// `gpt-4o`) and scores by match quality so callers can rank results. Higher
|
|
5
|
+
// score is a better match. Returns the matched character indices so callers
|
|
6
|
+
// can highlight them.
|
|
7
|
+
//
|
|
8
|
+
// The scoring favours, in rough order: exact full match, prefix match, matches
|
|
9
|
+
// that start on a word boundary (after `-`, `_`, `/`, `.`, space, or a
|
|
10
|
+
// lower→upper case transition), contiguous runs, and earlier matches. This is
|
|
11
|
+
// intentionally simple — no external dependency — but good enough to make
|
|
12
|
+
// `son4` rank `claude-sonnet-4` above an incidental scattered hit.
|
|
13
|
+
//
|
|
14
|
+
// This is a logically identical copy of ui-tui/src/lib/fuzzy.ts (only prettier
|
|
15
|
+
// formatting differs); keep the two in sync. The TUI copy carries the vitest
|
|
16
|
+
// suite (this `web` package has no test runner), so behavioural changes should
|
|
17
|
+
// be validated there.
|
|
18
|
+
|
|
19
|
+
export interface FuzzyMatch {
|
|
20
|
+
/** Total score; higher is better. */
|
|
21
|
+
score: number;
|
|
22
|
+
/** Indices into the original (non-lowercased) target that were matched. */
|
|
23
|
+
positions: number[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const WORD_BOUNDARY = /[-_/.\s]/;
|
|
27
|
+
|
|
28
|
+
function isBoundary(target: string, index: number): boolean {
|
|
29
|
+
if (index === 0) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const prev = target[index - 1];
|
|
34
|
+
|
|
35
|
+
if (WORD_BOUNDARY.test(prev)) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// camelCase / lower→upper transition (e.g. the `O` in `gptO`).
|
|
40
|
+
const cur = target[index];
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
prev === prev.toLowerCase() &&
|
|
44
|
+
cur !== cur.toLowerCase() &&
|
|
45
|
+
cur === cur.toUpperCase()
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Score a single query token against a target. Returns null when the token is
|
|
51
|
+
* not a subsequence of the target. An empty query scores 0 with no positions.
|
|
52
|
+
*/
|
|
53
|
+
export function fuzzyScore(target: string, query: string): FuzzyMatch | null {
|
|
54
|
+
if (!query) {
|
|
55
|
+
return { score: 0, positions: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lowerTarget = target.toLowerCase();
|
|
59
|
+
const lowerQuery = query.toLowerCase();
|
|
60
|
+
|
|
61
|
+
const positions: number[] = [];
|
|
62
|
+
let score = 0;
|
|
63
|
+
let prevIndex = -1;
|
|
64
|
+
let searchFrom = 0;
|
|
65
|
+
|
|
66
|
+
for (const ch of lowerQuery) {
|
|
67
|
+
const idx = lowerTarget.indexOf(ch, searchFrom);
|
|
68
|
+
|
|
69
|
+
if (idx < 0) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
positions.push(idx);
|
|
74
|
+
|
|
75
|
+
// Base point for the matched character.
|
|
76
|
+
score += 1;
|
|
77
|
+
|
|
78
|
+
// Contiguous with the previous match → strong bonus.
|
|
79
|
+
if (prevIndex >= 0 && idx === prevIndex + 1) {
|
|
80
|
+
score += 5;
|
|
81
|
+
} else if (prevIndex >= 0) {
|
|
82
|
+
// Penalise the gap we had to skip (capped), so contiguous beats scattered.
|
|
83
|
+
score -= Math.min(idx - prevIndex - 1, 3);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Word-boundary / start-of-string matches are meaningful.
|
|
87
|
+
if (isBoundary(target, idx)) {
|
|
88
|
+
score += 3;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Matching the very first character of the target is the strongest signal.
|
|
92
|
+
if (idx === 0) {
|
|
93
|
+
score += 5;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
prevIndex = idx;
|
|
97
|
+
searchFrom = idx + 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Prefix bonus: the query matched a contiguous prefix of the target.
|
|
101
|
+
if (
|
|
102
|
+
positions.length &&
|
|
103
|
+
positions[0] === 0 &&
|
|
104
|
+
positions[positions.length - 1] === positions.length - 1
|
|
105
|
+
) {
|
|
106
|
+
score += 8;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Exact full match dominates everything else.
|
|
110
|
+
if (lowerTarget === lowerQuery) {
|
|
111
|
+
score += 20;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Slightly prefer shorter targets when scores are otherwise close, so a
|
|
115
|
+
// query that fully prefixes a short id beats the same prefix on a long one.
|
|
116
|
+
score -= lowerTarget.length * 0.01;
|
|
117
|
+
|
|
118
|
+
return { score, positions };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Score a target against a whitespace-separated, multi-token query. Every token
|
|
123
|
+
* must match (AND semantics); the result aggregates per-token scores and the
|
|
124
|
+
* union of matched positions. Returns null if any token fails to match.
|
|
125
|
+
*/
|
|
126
|
+
export function fuzzyScoreMulti(
|
|
127
|
+
target: string,
|
|
128
|
+
query: string,
|
|
129
|
+
): FuzzyMatch | null {
|
|
130
|
+
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
131
|
+
|
|
132
|
+
if (!tokens.length) {
|
|
133
|
+
return { score: 0, positions: [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let score = 0;
|
|
137
|
+
const positionSet = new Set<number>();
|
|
138
|
+
|
|
139
|
+
for (const token of tokens) {
|
|
140
|
+
const match = fuzzyScore(target, token);
|
|
141
|
+
|
|
142
|
+
if (!match) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
score += match.score;
|
|
147
|
+
|
|
148
|
+
for (const pos of match.positions) {
|
|
149
|
+
positionSet.add(pos);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { score, positions: [...positionSet].sort((a, b) => a - b) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface RankedItem<T> {
|
|
157
|
+
item: T;
|
|
158
|
+
score: number;
|
|
159
|
+
positions: number[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Filter + rank a list by a fuzzy query against a derived text key. Non-matching
|
|
164
|
+
* items are dropped; matches are sorted by score (descending), ties broken by
|
|
165
|
+
* the original index so ordering is stable for equal scores. An empty query
|
|
166
|
+
* returns every item in original order with no positions.
|
|
167
|
+
*/
|
|
168
|
+
export function fuzzyRank<T>(
|
|
169
|
+
items: readonly T[],
|
|
170
|
+
query: string,
|
|
171
|
+
toText: (item: T) => string,
|
|
172
|
+
): RankedItem<T>[] {
|
|
173
|
+
const trimmed = query.trim();
|
|
174
|
+
|
|
175
|
+
if (!trimmed) {
|
|
176
|
+
return items.map((item) => ({ item, score: 0, positions: [] }));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ranked: Array<RankedItem<T> & { index: number }> = [];
|
|
180
|
+
|
|
181
|
+
items.forEach((item, index) => {
|
|
182
|
+
const match = fuzzyScoreMulti(toText(item), trimmed);
|
|
183
|
+
|
|
184
|
+
if (match) {
|
|
185
|
+
ranked.push({ item, score: match.score, positions: match.positions, index });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
ranked.sort((a, b) => b.score - a.score || a.index - b.index);
|
|
190
|
+
|
|
191
|
+
return ranked.map(({ item, score, positions }) => ({ item, score, positions }));
|
|
192
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser WebSocket client for the tui_gateway JSON-RPC protocol.
|
|
3
|
+
*
|
|
4
|
+
* Speaks the exact same newline-delimited JSON-RPC dialect that the Ink TUI
|
|
5
|
+
* drives over stdio. The server-side transport abstraction
|
|
6
|
+
* (tui_gateway/transport.py + ws.py) routes the same dispatcher's writes
|
|
7
|
+
* onto either stdout or a WebSocket depending on how the client connected.
|
|
8
|
+
*
|
|
9
|
+
* const gw = new GatewayClient()
|
|
10
|
+
* await gw.connect()
|
|
11
|
+
* const { session_id } = await gw.request<{ session_id: string }>("session.create")
|
|
12
|
+
* gw.on("message.delta", (ev) => console.log(ev.payload?.text))
|
|
13
|
+
* await gw.request("prompt.submit", { session_id, text: "hi" })
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { NASTECH_BASE_PATH, getWsTicket } from "@/lib/api";
|
|
17
|
+
|
|
18
|
+
export type GatewayEventName =
|
|
19
|
+
| "gateway.ready"
|
|
20
|
+
| "session.info"
|
|
21
|
+
| "message.start"
|
|
22
|
+
| "message.delta"
|
|
23
|
+
| "message.complete"
|
|
24
|
+
| "thinking.delta"
|
|
25
|
+
| "reasoning.delta"
|
|
26
|
+
| "reasoning.available"
|
|
27
|
+
| "status.update"
|
|
28
|
+
| "tool.start"
|
|
29
|
+
| "tool.progress"
|
|
30
|
+
| "tool.complete"
|
|
31
|
+
| "tool.generating"
|
|
32
|
+
| "clarify.request"
|
|
33
|
+
| "approval.request"
|
|
34
|
+
| "sudo.request"
|
|
35
|
+
| "secret.request"
|
|
36
|
+
| "background.complete"
|
|
37
|
+
| "error"
|
|
38
|
+
| "skin.changed"
|
|
39
|
+
| (string & {});
|
|
40
|
+
|
|
41
|
+
export interface GatewayEvent<P = unknown> {
|
|
42
|
+
type: GatewayEventName;
|
|
43
|
+
session_id?: string;
|
|
44
|
+
payload?: P;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ConnectionState =
|
|
48
|
+
| "idle"
|
|
49
|
+
| "connecting"
|
|
50
|
+
| "open"
|
|
51
|
+
| "closed"
|
|
52
|
+
| "error";
|
|
53
|
+
|
|
54
|
+
interface Pending {
|
|
55
|
+
resolve: (v: unknown) => void;
|
|
56
|
+
reject: (e: Error) => void;
|
|
57
|
+
timer: ReturnType<typeof setTimeout>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
|
61
|
+
|
|
62
|
+
/** Wildcard listener key: subscribe to every event regardless of type. */
|
|
63
|
+
const ANY = "*";
|
|
64
|
+
|
|
65
|
+
export class GatewayClient {
|
|
66
|
+
private ws: WebSocket | null = null;
|
|
67
|
+
private reqId = 0;
|
|
68
|
+
private pending = new Map<string, Pending>();
|
|
69
|
+
private listeners = new Map<string, Set<(ev: GatewayEvent) => void>>();
|
|
70
|
+
private _state: ConnectionState = "idle";
|
|
71
|
+
private stateListeners = new Set<(s: ConnectionState) => void>();
|
|
72
|
+
|
|
73
|
+
get state(): ConnectionState {
|
|
74
|
+
return this._state;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private setState(s: ConnectionState) {
|
|
78
|
+
if (this._state === s) return;
|
|
79
|
+
this._state = s;
|
|
80
|
+
for (const cb of this.stateListeners) cb(s);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
onState(cb: (s: ConnectionState) => void): () => void {
|
|
84
|
+
this.stateListeners.add(cb);
|
|
85
|
+
cb(this._state);
|
|
86
|
+
return () => this.stateListeners.delete(cb);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Subscribe to a specific event type. Returns an unsubscribe function. */
|
|
90
|
+
on<P = unknown>(
|
|
91
|
+
type: GatewayEventName,
|
|
92
|
+
cb: (ev: GatewayEvent<P>) => void,
|
|
93
|
+
): () => void {
|
|
94
|
+
let set = this.listeners.get(type);
|
|
95
|
+
if (!set) {
|
|
96
|
+
set = new Set();
|
|
97
|
+
this.listeners.set(type, set);
|
|
98
|
+
}
|
|
99
|
+
set.add(cb as (ev: GatewayEvent) => void);
|
|
100
|
+
return () => set!.delete(cb as (ev: GatewayEvent) => void);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Subscribe to every event (fires after type-specific listeners). */
|
|
104
|
+
onAny(cb: (ev: GatewayEvent) => void): () => void {
|
|
105
|
+
return this.on(ANY as GatewayEventName, cb);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async connect(token?: string): Promise<void> {
|
|
109
|
+
if (this._state === "open" || this._state === "connecting") return;
|
|
110
|
+
this.setState("connecting");
|
|
111
|
+
|
|
112
|
+
// Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the
|
|
113
|
+
// SPA must fetch a single-use ticket via /api/auth/ws-ticket instead.
|
|
114
|
+
// Explicit ``token`` overrides the gate check (test-only path).
|
|
115
|
+
let authParamName: string;
|
|
116
|
+
let authParamValue: string;
|
|
117
|
+
if (token) {
|
|
118
|
+
authParamName = "token";
|
|
119
|
+
authParamValue = token;
|
|
120
|
+
} else if (window.__NASTECH_AUTH_REQUIRED__) {
|
|
121
|
+
const { ticket } = await getWsTicket();
|
|
122
|
+
authParamName = "ticket";
|
|
123
|
+
authParamValue = ticket;
|
|
124
|
+
} else {
|
|
125
|
+
authParamName = "token";
|
|
126
|
+
authParamValue = window.__NASTECH_SESSION_TOKEN__ ?? "";
|
|
127
|
+
if (!authParamValue) {
|
|
128
|
+
this.setState("error");
|
|
129
|
+
throw new Error(
|
|
130
|
+
"Session token not available — page must be served by the NasTech dashboard",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
|
136
|
+
const ws = new WebSocket(
|
|
137
|
+
`${scheme}//${location.host}${NASTECH_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`,
|
|
138
|
+
);
|
|
139
|
+
this.ws = ws;
|
|
140
|
+
|
|
141
|
+
// Register message + close BEFORE awaiting open — the server emits
|
|
142
|
+
// `gateway.ready` immediately after accept, so a listener attached
|
|
143
|
+
// after the open promise resolves can race past it and drop the
|
|
144
|
+
// initial skin payload.
|
|
145
|
+
ws.addEventListener("message", (ev) => {
|
|
146
|
+
try {
|
|
147
|
+
this.dispatch(JSON.parse(ev.data));
|
|
148
|
+
} catch {
|
|
149
|
+
/* malformed frame — ignore */
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
ws.addEventListener("close", () => {
|
|
154
|
+
this.setState("closed");
|
|
155
|
+
this.rejectAllPending(new Error("WebSocket closed"));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await new Promise<void>((resolve, reject) => {
|
|
159
|
+
const onOpen = () => {
|
|
160
|
+
ws.removeEventListener("error", onError);
|
|
161
|
+
this.setState("open");
|
|
162
|
+
resolve();
|
|
163
|
+
};
|
|
164
|
+
const onError = () => {
|
|
165
|
+
ws.removeEventListener("open", onOpen);
|
|
166
|
+
this.setState("error");
|
|
167
|
+
reject(new Error("WebSocket connection failed"));
|
|
168
|
+
};
|
|
169
|
+
ws.addEventListener("open", onOpen, { once: true });
|
|
170
|
+
ws.addEventListener("error", onError, { once: true });
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
close() {
|
|
175
|
+
this.ws?.close();
|
|
176
|
+
this.ws = null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private dispatch(msg: Record<string, unknown>) {
|
|
180
|
+
const id = msg.id as string | undefined;
|
|
181
|
+
|
|
182
|
+
if (id !== undefined && this.pending.has(id)) {
|
|
183
|
+
const p = this.pending.get(id)!;
|
|
184
|
+
this.pending.delete(id);
|
|
185
|
+
clearTimeout(p.timer);
|
|
186
|
+
|
|
187
|
+
const err = msg.error as { message?: string } | undefined;
|
|
188
|
+
if (err) p.reject(new Error(err.message ?? "request failed"));
|
|
189
|
+
else p.resolve(msg.result);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (msg.method !== "event") return;
|
|
194
|
+
|
|
195
|
+
const params = (msg.params ?? {}) as GatewayEvent;
|
|
196
|
+
if (typeof params.type !== "string") return;
|
|
197
|
+
|
|
198
|
+
for (const cb of this.listeners.get(params.type) ?? []) cb(params);
|
|
199
|
+
for (const cb of this.listeners.get(ANY) ?? []) cb(params);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private rejectAllPending(err: Error) {
|
|
203
|
+
for (const p of this.pending.values()) {
|
|
204
|
+
clearTimeout(p.timer);
|
|
205
|
+
p.reject(err);
|
|
206
|
+
}
|
|
207
|
+
this.pending.clear();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Send a JSON-RPC request. Rejects on error response or timeout. */
|
|
211
|
+
request<T = unknown>(
|
|
212
|
+
method: string,
|
|
213
|
+
params: Record<string, unknown> = {},
|
|
214
|
+
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
215
|
+
): Promise<T> {
|
|
216
|
+
if (!this.ws || this._state !== "open") {
|
|
217
|
+
return Promise.reject(
|
|
218
|
+
new Error(`gateway not connected (state=${this._state})`),
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const id = `w${++this.reqId}`;
|
|
223
|
+
|
|
224
|
+
return new Promise<T>((resolve, reject) => {
|
|
225
|
+
const timer = setTimeout(() => {
|
|
226
|
+
if (this.pending.delete(id)) {
|
|
227
|
+
reject(new Error(`request timed out: ${method}`));
|
|
228
|
+
}
|
|
229
|
+
}, timeoutMs);
|
|
230
|
+
|
|
231
|
+
this.pending.set(id, {
|
|
232
|
+
resolve: (v) => resolve(v as T),
|
|
233
|
+
reject,
|
|
234
|
+
timer,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
|
|
239
|
+
} catch (e) {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
this.pending.delete(id);
|
|
242
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
declare global {
|
|
249
|
+
interface Window {
|
|
250
|
+
__NASTECH_SESSION_TOKEN__?: string;
|
|
251
|
+
__NASTECH_AUTH_REQUIRED__?: boolean;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
|
2
|
+
const parts = path.split(".");
|
|
3
|
+
let cur: unknown = obj;
|
|
4
|
+
for (const p of parts) {
|
|
5
|
+
if (cur == null || typeof cur !== "object") return undefined;
|
|
6
|
+
cur = (cur as Record<string, unknown>)[p];
|
|
7
|
+
}
|
|
8
|
+
return cur;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
|
12
|
+
const clone = structuredClone(obj);
|
|
13
|
+
const parts = path.split(".");
|
|
14
|
+
let cur: Record<string, unknown> = clone;
|
|
15
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
16
|
+
if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") {
|
|
17
|
+
cur[parts[i]] = {};
|
|
18
|
+
}
|
|
19
|
+
cur = cur[parts[i]] as Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
cur[parts[parts.length - 1]] = value;
|
|
22
|
+
return clone;
|
|
23
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Translations } from "@/i18n/types";
|
|
2
|
+
|
|
3
|
+
const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
|
|
4
|
+
"/chat": "chat",
|
|
5
|
+
"/sessions": "sessions",
|
|
6
|
+
"/analytics": "analytics",
|
|
7
|
+
"/models": "models",
|
|
8
|
+
"/logs": "logs",
|
|
9
|
+
"/cron": "cron",
|
|
10
|
+
"/skills": "skills",
|
|
11
|
+
"/plugins": "plugins",
|
|
12
|
+
"/profiles": "profiles",
|
|
13
|
+
"/config": "config",
|
|
14
|
+
"/env": "keys",
|
|
15
|
+
"/docs": "documentation",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function resolvePageTitle(
|
|
19
|
+
pathname: string,
|
|
20
|
+
t: Translations,
|
|
21
|
+
pluginTabs: { path: string; label: string }[],
|
|
22
|
+
): string {
|
|
23
|
+
const normalized = pathname.replace(/\/$/, "") || "/";
|
|
24
|
+
if (normalized === "/") {
|
|
25
|
+
return t.app.nav.sessions;
|
|
26
|
+
}
|
|
27
|
+
const plugin = pluginTabs.find((p) => p.path === normalized);
|
|
28
|
+
if (plugin) {
|
|
29
|
+
return plugin.label;
|
|
30
|
+
}
|
|
31
|
+
const key = BUILTIN[normalized];
|
|
32
|
+
if (key) {
|
|
33
|
+
return t.app.nav[key];
|
|
34
|
+
}
|
|
35
|
+
// Derive title from pathname: "/profiles" → "Profiles"
|
|
36
|
+
const segment = normalized.slice(1);
|
|
37
|
+
if (segment) {
|
|
38
|
+
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
|
39
|
+
}
|
|
40
|
+
return t.app.webUi;
|
|
41
|
+
}
|