@linxin666/dsh-remote-web-ui 0.3.0 → 0.3.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AAa7G,OAAO,EAAU,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAarD,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACzD,YAAY,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACrE,YAAY,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AACnE,YAAY,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAC7C,YAAY,EAAE,sBAAsB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAA;AAC/F,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACzD,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAErE,OAAO,QAAQ,kCAAkC,CAAC;IAChD,UAAU,kBAAkB;QAC1B,0CAA0C;QAC1C,MAAM,EAAE,SAAS,CAAA;KAClB;IAED,UAAU,OAAO;QACf;;;;WAIG;QACH,gBAAgB,EAAE;YAAE,IAAI,EAAE,QAAQ,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,uBAAuB,CAAA;SAAE,CAAA;QACnF;;;;;WAKG;QACH,oBAAoB,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,4BAA4B,CAAA;SAAE,CAAA;KAC3F;CACF;AAED,4GAA4G;AAC5G,MAAM,WAAW,uBAAuB;IACtC,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;CACd;AAED,mEAAmE;AACnE,MAAM,WAAW,4BAA4B;IAC3C,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,KAAK,CAAA;CACjB;AAED,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAYD,wCAAwC;AACxC,eAAO,MAAM,MAAM,UAA+D,CAAA;AAElF;;;GAGG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAkN9C"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/client/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAA;AAa7G,OAAO,EAAU,KAAK,SAAS,EAAE,MAAM,cAAc,CAAA;AAarD,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACzD,YAAY,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACrE,YAAY,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAA;AACnE,YAAY,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAC7C,YAAY,EAAE,sBAAsB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAA;AAC/F,YAAY,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAA;AACzD,YAAY,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAErE,OAAO,QAAQ,kCAAkC,CAAC;IAChD,UAAU,kBAAkB;QAC1B,0CAA0C;QAC1C,MAAM,EAAE,SAAS,CAAA;KAClB;IAED,UAAU,OAAO;QACf;;;;WAIG;QACH,gBAAgB,EAAE;YAAE,IAAI,EAAE,QAAQ,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,uBAAuB,CAAA;SAAE,CAAA;QACnF;;;;;WAKG;QACH,oBAAoB,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,4BAA4B,CAAA;SAAE,CAAA;KAC3F;CACF;AAED,4GAA4G;AAC5G,MAAM,WAAW,uBAAuB;IACtC,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;CACd;AAED,mEAAmE;AACnE,MAAM,WAAW,4BAA4B;IAC3C,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,KAAK,CAAA;CACjB;AAED,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf;;;;WAIG;QACH,aAAa,CAAC,EAAE;YAAE,IAAI,CAAC,CAAC,EAAE,IAAI,EAAE,iBAAiB,CAAC,CAAC,CAAC,GAAG,aAAa,CAAC,CAAC,CAAC,CAAA;SAAE,CAAA;KAC1E;CACF;AAYD,wCAAwC;AACxC,eAAO,MAAM,MAAM,UAA+D,CAAA;AAElF;;;GAGG;AACH,wBAAgB,KAAK,CAAC,GAAG,EAAE,aAAa,GAAG,IAAI,CAoO9C"}
@@ -30,7 +30,14 @@ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote
30
30
  * @param ctx - client root context.
31
31
  */
32
32
  export function apply(ctx) {
33
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'remote-web-ui: dictionaries');
33
+ ctx.effect(() => {
34
+ try {
35
+ return ctx.locale.register(NS, { zh, en });
36
+ }
37
+ catch {
38
+ return () => { };
39
+ }
40
+ }, 'remote-web-ui: dictionaries');
34
41
  const t = ctx.locale.bind(NS);
35
42
  const binder = ctx.get('webUiSettings') ?? ctx.settingsScope;
36
43
  const settingsScope = binder.bind({ namespace: REMOTE_WEB_UI_NS });
@@ -49,7 +56,12 @@ export function apply(ctx) {
49
56
  let disposeEntry;
50
57
  const syncEntry = () => {
51
58
  if (enabled() && disposeEntry === undefined) {
52
- disposeEntry = ctx.slots.register({ name: 'sidebar.remote', locale: NS }, RemoteEntry);
59
+ try {
60
+ disposeEntry = ctx.slots.register({ name: 'sidebar.remote', locale: NS }, RemoteEntry);
61
+ }
62
+ catch {
63
+ // ignore registration collision
64
+ }
53
65
  }
54
66
  else if (!enabled() && disposeEntry !== undefined) {
55
67
  disposeEntry();
@@ -71,7 +83,12 @@ export function apply(ctx) {
71
83
  let disposeEntry;
72
84
  const syncEntry = () => {
73
85
  if (enabled() && disposeEntry === undefined) {
74
- disposeEntry = ctx.slots.register({ name: 'sidebar.footer.action', id: 'remote-web-ui', locale: NS }, FooterRemoteEntry);
86
+ try {
87
+ disposeEntry = ctx.slots.register({ name: 'sidebar.footer.action', id: 'remote-web-ui', locale: NS }, FooterRemoteEntry);
88
+ }
89
+ catch {
90
+ // ignore registration collision
91
+ }
75
92
  }
76
93
  else if (!enabled() && disposeEntry !== undefined) {
77
94
  disposeEntry();
@@ -89,17 +106,22 @@ export function apply(ctx) {
89
106
  // settings namespace, contributed to the Web UI plugin group.
90
107
  const remoteSettings = new RemoteSettingsCardController(settingsScope);
91
108
  ctx.slots.inject('web-ui.plugin.item', () => {
92
- const unregister = ctx.slots.register({
93
- name: 'web-ui.plugin.item',
94
- id: 'remote-web-ui',
95
- order: 90,
96
- locale: NS,
97
- inject: () => remoteSettings.inject(),
98
- }, RemoteSettingsCard);
99
- return () => {
100
- remoteSettings.dispose();
101
- unregister();
102
- };
109
+ try {
110
+ const unregister = ctx.slots.register({
111
+ name: 'web-ui.plugin.item',
112
+ id: 'remote-web-ui',
113
+ order: 90,
114
+ locale: NS,
115
+ inject: () => remoteSettings.inject(),
116
+ }, RemoteSettingsCard);
117
+ return () => {
118
+ remoteSettings.dispose();
119
+ unregister();
120
+ };
121
+ }
122
+ catch {
123
+ return () => { };
124
+ }
103
125
  });
104
126
  // Phone-side boot flow + heartbeats. Loopback pages (the desktop) never
105
127
  // heartbeat; the server ignores unpaired heartbeats anyway. Both run only
@@ -47,7 +47,7 @@ function DirectoryBrowser({ onCancel, onPick }) {
47
47
  setError(errorText(reason));
48
48
  }
49
49
  };
50
- return (_jsxs("div", { className: "mobile dir-browser", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("button", { type: "button", className: "mobile-headerAction", onClick: onCancel, children: "\u8FD4\u56DE" }), _jsx("h1", { className: "mobile-title", children: "\u9009\u62E9\u76EE\u5F55" })] }), listing !== undefined && (_jsx("div", { className: "dir-crumbs", children: listing.crumbs.map((crumb, idx) => (_jsxs("span", { children: [_jsx("button", { type: "button", className: "dir-crumb", onClick: () => setPath(crumb.path), children: crumb.name || '/' }), idx < listing.crumbs.length - 1 && _jsx("span", { className: "dir-crumb-separator", children: "/" })] }, crumb.path))) })), error !== undefined ? (_jsxs("div", { className: "mobile-empty", children: [_jsx("p", { className: "mobile-error", children: error }), _jsx("button", { type: "button", className: "mobile-button", onClick: () => setReload(n => n + 1), children: "\u91CD\u8BD5" })] })) : listing === undefined ? (_jsx("div", { className: "mobile-empty", children: _jsx("p", { className: "mobile-muted", children: "\u52A0\u8F7D\u4E2D\u2026" }) })) : (_jsx("ul", { className: "mobile-list", children: listing.entries.length === 0 ? (_jsx("div", { className: "mobile-empty dir-empty", children: _jsx("p", { className: "mobile-muted", children: "\u7A7A\u76EE\u5F55" }) })) : (listing.entries.map((entry) => (_jsx("li", { children: _jsx("button", { type: "button", className: `mobile-row dir-entry ${entry.hidden ? 'dir-entry-hidden' : ''}`, onClick: () => setPath(entry.path), children: _jsxs("span", { className: "mobile-rowTitle", children: ["\uD83D\uDCC1 ", entry.name] }) }) }, entry.path)))) })), _jsx("div", { className: "dir-select", children: _jsx("button", { type: "button", className: "mobile-button", disabled: listing === undefined || creating, onClick: () => void handleCreate(), children: creating ? '创建中…' : '选择此目录' }) })] }));
50
+ return (_jsxs("div", { className: "mobile dir-browser", children: [_jsxs("header", { className: "mobile-header", children: [_jsx("button", { type: "button", className: "mobile-headerAction", onClick: onCancel, children: "\u8FD4\u56DE" }), _jsx("h1", { className: "mobile-title", children: "\u9009\u62E9\u76EE\u5F55" })] }), listing !== undefined && (_jsx("div", { className: "dir-crumbs", children: listing.crumbs.map((crumb, idx) => (_jsxs("span", { children: [_jsx("button", { type: "button", className: "dir-crumb", onClick: () => setPath(crumb.path), children: crumb.name || '/' }), idx < listing.crumbs.length - 1 && _jsx("span", { className: "dir-crumb-separator", children: "/" })] }, crumb.path))) })), error !== undefined ? (_jsxs("div", { className: "mobile-empty", children: [_jsx("p", { className: "mobile-error", children: error }), _jsx("button", { type: "button", className: "mobile-button", onClick: () => setReload(n => n + 1), children: "\u91CD\u8BD5" })] })) : listing === undefined ? (_jsx("div", { className: "mobile-empty", children: _jsx("p", { className: "mobile-muted", children: "\u52A0\u8F7D\u4E2D\u2026" }) })) : (_jsx("ul", { className: "mobile-list", children: listing.entries.length === 0 ? (_jsx("div", { className: "mobile-empty dir-empty", children: _jsx("p", { className: "mobile-muted", children: "\u7A7A\u76EE\u5F55" }) })) : (listing.entries.map((entry) => (_jsx("li", { children: _jsx("button", { type: "button", className: `mobile-row dir-entry ${entry.hidden ? 'dir-entry-hidden' : ''}`, onClick: () => setPath(entry.path), children: _jsx("span", { className: "mobile-rowTitle", children: entry.name }) }) }, entry.path)))) })), _jsx("div", { className: "dir-select", children: _jsx("button", { type: "button", className: "mobile-button", disabled: listing === undefined || creating, onClick: () => void handleCreate(), children: creating ? '创建中…' : '选择此目录' }) })] }));
51
51
  }
52
52
  /**
53
53
  * Render the workspace roster.
@@ -82,8 +82,8 @@ describe('mobile workspace creation', () => {
82
82
  // Verify crumbs
83
83
  expect(await screen.findByText('user')).toBeTruthy();
84
84
  // Verify entries
85
- expect(await screen.findByText('📁 projects')).toBeTruthy();
86
- expect(await screen.findByText('📁 .config')).toBeTruthy();
85
+ expect(await screen.findByText('projects')).toBeTruthy();
86
+ expect(await screen.findByText('.config')).toBeTruthy();
87
87
  });
88
88
  it('navigates into directory and allows breadcrumb navigation', async () => {
89
89
  listWorkspacesMock.mockResolvedValue(workspaces);
@@ -100,7 +100,7 @@ describe('mobile workspace creation', () => {
100
100
  // Open dir browser
101
101
  fireEvent.click(await screen.findByText('+ 新建工作区'));
102
102
  // Wait for first dir listing
103
- const projBtn = await screen.findByText('📁 projects');
103
+ const projBtn = await screen.findByText('projects');
104
104
  // Prepare next listing
105
105
  listDirectoryMock.mockResolvedValueOnce({
106
106
  path: '/home/user/projects',
@@ -117,7 +117,7 @@ describe('mobile workspace creation', () => {
117
117
  // Click into folder
118
118
  fireEvent.click(projBtn);
119
119
  // Verify new contents load
120
- expect(await screen.findByText('📁 foo')).toBeTruthy();
120
+ expect(await screen.findByText('foo')).toBeTruthy();
121
121
  // Check breadcrumb
122
122
  const homeCrumb = screen.getByText('home');
123
123
  expect(homeCrumb).toBeTruthy();
@@ -130,7 +130,7 @@ describe('mobile workspace creation', () => {
130
130
  truncated: false
131
131
  });
132
132
  fireEvent.click(homeCrumb);
133
- expect(await screen.findByText('📁 user')).toBeTruthy();
133
+ expect(await screen.findByText('user')).toBeTruthy();
134
134
  });
135
135
  it('creates workspace successfully', async () => {
136
136
  listWorkspacesMock.mockResolvedValue(workspaces);
@@ -1 +1 @@
1
- {"version":3,"file":"paired-model-catalog.d.ts","sourceRoot":"","sources":["../../src/paired-model-catalog.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAiE,MAAM,oCAAoC,CAAA;AACjI,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAE/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AA6BlD,eAAO,MAAM,0BAA0B;;;;CAI7B,CAAA;AAEV,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,cAAc,CAAA;IACvB,QAAQ,EAAE,QAAQ,CAAA;IAClB,iFAAiF;IACjF,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAsGD,2DAA2D;AAC3D,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,sBAAsB,GAAG,QAAQ,EAAE,CA2ErF"}
1
+ {"version":3,"file":"paired-model-catalog.d.ts","sourceRoot":"","sources":["../../src/paired-model-catalog.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,QAAQ,EAAqF,MAAM,oCAAoC,CAAA;AACrJ,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAE/D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAA;AA6BlD,eAAO,MAAM,0BAA0B;;;;CAI7B,CAAA;AAEV,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,cAAc,CAAA;IACvB,QAAQ,EAAE,QAAQ,CAAA;IAClB,iFAAiF;IACjF,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAsJD,2DAA2D;AAC3D,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,sBAAsB,GAAG,QAAQ,EAAE,CA2ErF"}
@@ -67,7 +67,11 @@ function fieldsOf(model, includeId) {
67
67
  fields.reasoningEfforts = Object.fromEntries(model.reasoningEfforts.map(effort => [effort, effort === 'off' ? null : effort]));
68
68
  return fields;
69
69
  }
70
- function groupFor(catalog, provider) { return catalog.groups.find(group => group.id === provider); }
70
+ function groupFor(catalog, provider) {
71
+ if (catalog.failures.some(failure => failure.id === provider))
72
+ return undefined;
73
+ return catalog.groups.find(group => group.id === provider);
74
+ }
71
75
  async function readCatalog(apiProxy) {
72
76
  try {
73
77
  return valueOf(await apiProxy.llm.models(request({})));
@@ -105,29 +109,84 @@ async function eligibleProvider(apiProxy, requested) {
105
109
  return { status: 403, error: `provider ${requested} is not eligible for the paired model catalog` };
106
110
  return { provider: { provider: requested, displayName: route.displayName, namespace, profile } };
107
111
  }
112
+ function validIdentifier(value) {
113
+ const parsed = stringField(MAX_IDENTIFIER_LENGTH).safeParse(value);
114
+ return parsed.success && parsed.data === value;
115
+ }
116
+ function configuredModels(profile) {
117
+ if (!Object.hasOwn(profile, 'models'))
118
+ return { inherited: true };
119
+ if (!Array.isArray(profile.models))
120
+ return undefined;
121
+ if (profile.models.length === 0)
122
+ return { inherited: true };
123
+ const values = [];
124
+ for (const entry of profile.models) {
125
+ if (!isRecord(entry) || !validIdentifier(entry.id))
126
+ return undefined;
127
+ values.push({ ...entry });
128
+ }
129
+ return { inherited: false, values };
130
+ }
131
+ function configuredOverrides(profile) {
132
+ if (!Object.hasOwn(profile, 'modelOverrides'))
133
+ return { present: false, values: new Map() };
134
+ if (!isRecord(profile.modelOverrides))
135
+ return undefined;
136
+ const values = new Map();
137
+ for (const [id, value] of Object.entries(profile.modelOverrides)) {
138
+ if (!validIdentifier(id) || !isRecord(value))
139
+ return undefined;
140
+ values.set(id, { ...value });
141
+ }
142
+ return { present: true, values };
143
+ }
108
144
  function updatedModels(existing, model) {
109
- const next = existing.map(entry => isRecord(entry) ? { ...entry } : entry);
110
- const index = next.findIndex(entry => isRecord(entry) && entry.id === model.id);
145
+ const next = existing.map(entry => ({ ...entry }));
146
+ const index = next.findIndex(entry => entry.id === model.id);
111
147
  const patch = fieldsOf(model, true);
112
- if (index >= 0 && isRecord(next[index]))
148
+ if (index >= 0)
113
149
  next[index] = { ...next[index], ...patch };
114
150
  else
115
151
  next.push(patch);
116
152
  return next;
117
153
  }
118
154
  function mutationPlan(profile, provider, model, catalog) {
119
- if (Array.isArray(profile.models))
120
- return { path: ['providers', provider, 'models'], value: updatedModels(profile.models, model) };
155
+ const configured = configuredModels(profile);
156
+ const overrides = configuredOverrides(profile);
157
+ if (configured === undefined || overrides === undefined)
158
+ return undefined;
159
+ const modelsPath = ['providers', provider, 'models'];
160
+ const overridesPath = ['providers', provider, 'modelOverrides'];
161
+ if (!configured.inherited) {
162
+ if (overrides.values.size > 0)
163
+ return undefined;
164
+ const ops = [{ op: 'set', path: modelsPath, value: updatedModels(configured.values, model) }];
165
+ if (overrides.present)
166
+ ops.push({ op: 'unset', path: overridesPath });
167
+ return { ops };
168
+ }
121
169
  const group = groupFor(catalog, provider);
122
170
  if (group?.models.some(entry => entry.id === model.id) === true) {
123
- const overrides = isRecord(profile.modelOverrides) ? profile.modelOverrides : undefined;
124
- const current = overrides?.[model.id];
125
- const existing = isRecord(current) ? current : {};
126
- return { path: ['providers', provider, 'modelOverrides', model.id], value: { ...existing, ...fieldsOf(model, false) } };
171
+ const existing = overrides.values.get(model.id) ?? {};
172
+ return { ops: [{ op: 'set', path: [...overridesPath, model.id], value: { ...existing, ...fieldsOf(model, false) } }] };
127
173
  }
128
- if (group === undefined || catalog.failures.some(failure => failure.id === provider))
174
+ if (group === undefined)
129
175
  return undefined;
130
- return { path: ['providers', provider, 'models'], value: [...group.models.map(entry => ({ id: entry.id })), fieldsOf(model, true)] };
176
+ const materialized = [];
177
+ const materializedIds = new Set();
178
+ for (const installed of group.models) {
179
+ materialized.push({ ...overrides.values.get(installed.id), id: installed.id });
180
+ materializedIds.add(installed.id);
181
+ }
182
+ for (const [id, override] of overrides.values) {
183
+ if (!materializedIds.has(id))
184
+ materialized.push({ ...override, id });
185
+ }
186
+ const ops = [{ op: 'set', path: modelsPath, value: updatedModels(materialized, model) }];
187
+ if (overrides.present)
188
+ ops.push({ op: 'unset', path: overridesPath });
189
+ return { ops };
131
190
  }
132
191
  function paired(req, service) {
133
192
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
@@ -227,7 +286,7 @@ export function makePairedModelCatalogRoutes(deps) {
227
286
  return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`);
228
287
  let mutation;
229
288
  try {
230
- mutation = await apiProxy.settings.mutate(request({ ns: 'llm-pi-ai', expectedRevision: eligible.provider.namespace.revision, ops: [{ op: 'set', path: plan.path, value: plan.value }] }));
289
+ mutation = await apiProxy.settings.mutate(request({ ns: 'llm-pi-ai', expectedRevision: eligible.provider.namespace.revision, ops: plan.ops }));
231
290
  }
232
291
  catch {
233
292
  return reject(res, 502, `model update failed for provider ${body.provider}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Scan-to-pair mobile and PC remote control for the dsh web GUI: QR entry beside the settings button, one-time pairing tokens, a gated /remote/api desktop channel, live device status, and revocable sessions",
4
- "version": "0.3.0",
4
+ "version": "0.3.2",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -106,7 +106,13 @@ export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote
106
106
  * @param ctx - client root context.
107
107
  */
108
108
  export function apply(ctx: ClientContext): void {
109
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'remote-web-ui: dictionaries')
109
+ ctx.effect(() => {
110
+ try {
111
+ return ctx.locale.register(NS, { zh, en })
112
+ } catch {
113
+ return () => {}
114
+ }
115
+ }, 'remote-web-ui: dictionaries')
110
116
 
111
117
  const t = ctx.locale.bind(NS)
112
118
  const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
@@ -127,7 +133,11 @@ export function apply(ctx: ClientContext): void {
127
133
  let disposeEntry: (() => void) | undefined
128
134
  const syncEntry = (): void => {
129
135
  if (enabled() && disposeEntry === undefined) {
130
- disposeEntry = ctx.slots.register({ name: 'sidebar.remote', locale: NS }, RemoteEntry)
136
+ try {
137
+ disposeEntry = ctx.slots.register({ name: 'sidebar.remote', locale: NS }, RemoteEntry)
138
+ } catch {
139
+ // ignore registration collision
140
+ }
131
141
  } else if (!enabled() && disposeEntry !== undefined) {
132
142
  disposeEntry()
133
143
  disposeEntry = undefined
@@ -149,7 +159,11 @@ export function apply(ctx: ClientContext): void {
149
159
  let disposeEntry: (() => void) | undefined
150
160
  const syncEntry = (): void => {
151
161
  if (enabled() && disposeEntry === undefined) {
152
- disposeEntry = ctx.slots.register({ name: 'sidebar.footer.action', id: 'remote-web-ui', locale: NS }, FooterRemoteEntry)
162
+ try {
163
+ disposeEntry = ctx.slots.register({ name: 'sidebar.footer.action', id: 'remote-web-ui', locale: NS }, FooterRemoteEntry)
164
+ } catch {
165
+ // ignore registration collision
166
+ }
153
167
  } else if (!enabled() && disposeEntry !== undefined) {
154
168
  disposeEntry()
155
169
  disposeEntry = undefined
@@ -167,16 +181,20 @@ export function apply(ctx: ClientContext): void {
167
181
  // settings namespace, contributed to the Web UI plugin group.
168
182
  const remoteSettings = new RemoteSettingsCardController(settingsScope)
169
183
  ctx.slots.inject('web-ui.plugin.item', () => {
170
- const unregister = ctx.slots.register({
171
- name: 'web-ui.plugin.item',
172
- id: 'remote-web-ui',
173
- order: 90,
174
- locale: NS,
175
- inject: () => remoteSettings.inject(),
176
- }, RemoteSettingsCard)
177
- return () => {
178
- remoteSettings.dispose()
179
- unregister()
184
+ try {
185
+ const unregister = ctx.slots.register({
186
+ name: 'web-ui.plugin.item',
187
+ id: 'remote-web-ui',
188
+ order: 90,
189
+ locale: NS,
190
+ inject: () => remoteSettings.inject(),
191
+ }, RemoteSettingsCard)
192
+ return () => {
193
+ remoteSettings.dispose()
194
+ unregister()
195
+ }
196
+ } catch {
197
+ return () => {}
180
198
  }
181
199
  })
182
200
 
@@ -100,8 +100,8 @@ describe('mobile workspace creation', () => {
100
100
  expect(await screen.findByText('user')).toBeTruthy()
101
101
 
102
102
  // Verify entries
103
- expect(await screen.findByText('📁 projects')).toBeTruthy()
104
- expect(await screen.findByText('📁 .config')).toBeTruthy()
103
+ expect(await screen.findByText('projects')).toBeTruthy()
104
+ expect(await screen.findByText('.config')).toBeTruthy()
105
105
  })
106
106
 
107
107
  it('navigates into directory and allows breadcrumb navigation', async () => {
@@ -123,7 +123,7 @@ describe('mobile workspace creation', () => {
123
123
  fireEvent.click(await screen.findByText('+ 新建工作区'))
124
124
 
125
125
  // Wait for first dir listing
126
- const projBtn = await screen.findByText('📁 projects')
126
+ const projBtn = await screen.findByText('projects')
127
127
 
128
128
  // Prepare next listing
129
129
  listDirectoryMock.mockResolvedValueOnce({
@@ -143,7 +143,7 @@ describe('mobile workspace creation', () => {
143
143
  fireEvent.click(projBtn)
144
144
 
145
145
  // Verify new contents load
146
- expect(await screen.findByText('📁 foo')).toBeTruthy()
146
+ expect(await screen.findByText('foo')).toBeTruthy()
147
147
 
148
148
  // Check breadcrumb
149
149
  const homeCrumb = screen.getByText('home')
@@ -159,7 +159,7 @@ describe('mobile workspace creation', () => {
159
159
  })
160
160
 
161
161
  fireEvent.click(homeCrumb)
162
- expect(await screen.findByText('📁 user')).toBeTruthy()
162
+ expect(await screen.findByText('user')).toBeTruthy()
163
163
  })
164
164
 
165
165
  it('creates workspace successfully', async () => {
@@ -104,7 +104,7 @@ function DirectoryBrowser({ onCancel, onPick }: DirectoryBrowserProps) {
104
104
  className={`mobile-row dir-entry ${entry.hidden ? 'dir-entry-hidden' : ''}`}
105
105
  onClick={() => setPath(entry.path)}
106
106
  >
107
- <span className="mobile-rowTitle">📁 {entry.name}</span>
107
+ <span className="mobile-rowTitle">{entry.name}</span>
108
108
  </button>
109
109
  </li>
110
110
  ))
@@ -1,6 +1,6 @@
1
1
  /** Paired-only model discovery and adoption routes; generic privileged RPCs stay loopback-only. */
2
2
  import type { IncomingMessage, ServerResponse } from 'node:http'
3
- import type { ApiProxy, ModelProviderGroup, RpcId, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-host-apiproxy/api'
3
+ import type { ApiProxy, ModelProviderGroup, RpcId, RpcResponse, SettingsNamespaceView, SettingsPathOpView } from '@deepseek-ai/dsh-host-apiproxy/api'
4
4
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
5
5
  import { z } from 'zod'
6
6
  import type { PairingService } from './pairing.ts'
@@ -90,7 +90,10 @@ function fieldsOf(model: ModelProfile, includeId: boolean): Record<string, unkno
90
90
  if (model.reasoningEfforts !== undefined) fields.reasoningEfforts = Object.fromEntries(model.reasoningEfforts.map(effort => [effort, effort === 'off' ? null : effort]))
91
91
  return fields
92
92
  }
93
- function groupFor(catalog: CatalogValue, provider: string): ModelProviderGroup | undefined { return catalog.groups.find(group => group.id === provider) }
93
+ function groupFor(catalog: CatalogValue, provider: string): ModelProviderGroup | undefined {
94
+ if (catalog.failures.some(failure => failure.id === provider)) return undefined
95
+ return catalog.groups.find(group => group.id === provider)
96
+ }
94
97
  async function readCatalog(apiProxy: ApiProxy): Promise<CatalogValue | undefined> {
95
98
  try { return valueOf(await apiProxy.llm.models(request({}))) } catch { return undefined }
96
99
  }
@@ -117,25 +120,70 @@ async function eligibleProvider(apiProxy: ApiProxy, requested: string): Promise<
117
120
  if (namespace === undefined || profile === undefined) return { status: 403, error: `provider ${requested} is not eligible for the paired model catalog` }
118
121
  return { provider: { provider: requested, displayName: route.displayName, namespace, profile } }
119
122
  }
120
- function updatedModels(existing: unknown[], model: ModelProfile): unknown[] {
121
- const next = existing.map(entry => isRecord(entry) ? { ...entry } : entry)
122
- const index = next.findIndex(entry => isRecord(entry) && entry.id === model.id)
123
+ function validIdentifier(value: unknown): value is string {
124
+ const parsed = stringField(MAX_IDENTIFIER_LENGTH).safeParse(value)
125
+ return parsed.success && parsed.data === value
126
+ }
127
+ function configuredModels(profile: Record<string, unknown>): { inherited: true } | { inherited: false, values: Record<string, unknown>[] } | undefined {
128
+ if (!Object.hasOwn(profile, 'models')) return { inherited: true }
129
+ if (!Array.isArray(profile.models)) return undefined
130
+ if (profile.models.length === 0) return { inherited: true }
131
+ const values: Record<string, unknown>[] = []
132
+ for (const entry of profile.models) {
133
+ if (!isRecord(entry) || !validIdentifier(entry.id)) return undefined
134
+ values.push({ ...entry })
135
+ }
136
+ return { inherited: false, values }
137
+ }
138
+ function configuredOverrides(profile: Record<string, unknown>): { present: boolean, values: Map<string, Record<string, unknown>> } | undefined {
139
+ if (!Object.hasOwn(profile, 'modelOverrides')) return { present: false, values: new Map() }
140
+ if (!isRecord(profile.modelOverrides)) return undefined
141
+ const values = new Map<string, Record<string, unknown>>()
142
+ for (const [id, value] of Object.entries(profile.modelOverrides)) {
143
+ if (!validIdentifier(id) || !isRecord(value)) return undefined
144
+ values.set(id, { ...value })
145
+ }
146
+ return { present: true, values }
147
+ }
148
+ function updatedModels(existing: Record<string, unknown>[], model: ModelProfile): Record<string, unknown>[] {
149
+ const next = existing.map(entry => ({ ...entry }))
150
+ const index = next.findIndex(entry => entry.id === model.id)
123
151
  const patch = fieldsOf(model, true)
124
- if (index >= 0 && isRecord(next[index])) next[index] = { ...next[index], ...patch }
152
+ if (index >= 0) next[index] = { ...next[index], ...patch }
125
153
  else next.push(patch)
126
154
  return next
127
155
  }
128
- function mutationPlan(profile: Record<string, unknown>, provider: string, model: ModelProfile, catalog: CatalogValue): { path: string[], value: unknown } | undefined {
129
- if (Array.isArray(profile.models)) return { path: ['providers', provider, 'models'], value: updatedModels(profile.models, model) }
156
+ function mutationPlan(profile: Record<string, unknown>, provider: string, model: ModelProfile, catalog: CatalogValue): { ops: SettingsPathOpView[] } | undefined {
157
+ const configured = configuredModels(profile)
158
+ const overrides = configuredOverrides(profile)
159
+ if (configured === undefined || overrides === undefined) return undefined
160
+ const modelsPath = ['providers', provider, 'models']
161
+ const overridesPath = ['providers', provider, 'modelOverrides']
162
+ if (!configured.inherited) {
163
+ if (overrides.values.size > 0) return undefined
164
+ const ops: SettingsPathOpView[] = [{ op: 'set', path: modelsPath, value: updatedModels(configured.values, model) }]
165
+ if (overrides.present) ops.push({ op: 'unset', path: overridesPath })
166
+ return { ops }
167
+ }
168
+
130
169
  const group = groupFor(catalog, provider)
131
170
  if (group?.models.some(entry => entry.id === model.id) === true) {
132
- const overrides = isRecord(profile.modelOverrides) ? profile.modelOverrides : undefined
133
- const current = overrides?.[model.id]
134
- const existing: Record<string, unknown> = isRecord(current) ? current : {}
135
- return { path: ['providers', provider, 'modelOverrides', model.id], value: { ...existing, ...fieldsOf(model, false) } }
171
+ const existing = overrides.values.get(model.id) ?? {}
172
+ return { ops: [{ op: 'set', path: [...overridesPath, model.id], value: { ...existing, ...fieldsOf(model, false) } }] }
173
+ }
174
+ if (group === undefined) return undefined
175
+ const materialized: Record<string, unknown>[] = []
176
+ const materializedIds = new Set<string>()
177
+ for (const installed of group.models) {
178
+ materialized.push({ ...overrides.values.get(installed.id), id: installed.id })
179
+ materializedIds.add(installed.id)
180
+ }
181
+ for (const [id, override] of overrides.values) {
182
+ if (!materializedIds.has(id)) materialized.push({ ...override, id })
136
183
  }
137
- if (group === undefined || catalog.failures.some(failure => failure.id === provider)) return undefined
138
- return { path: ['providers', provider, 'models'], value: [...group.models.map(entry => ({ id: entry.id })), fieldsOf(model, true)] }
184
+ const ops: SettingsPathOpView[] = [{ op: 'set', path: modelsPath, value: updatedModels(materialized, model) }]
185
+ if (overrides.present) ops.push({ op: 'unset', path: overridesPath })
186
+ return { ops }
139
187
  }
140
188
  function paired(req: IncomingMessage, service: PairingService): boolean {
141
189
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
@@ -207,7 +255,7 @@ export function makePairedModelCatalogRoutes(deps: PairedModelCatalogDeps): WebR
207
255
  const plan = mutationPlan(eligible.provider.profile, body.provider, body.model, before)
208
256
  if (plan === undefined) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`)
209
257
  let mutation: Awaited<ReturnType<ApiProxy['settings']['mutate']>>
210
- try { mutation = await apiProxy.settings.mutate(request({ ns: 'llm-pi-ai', expectedRevision: eligible.provider.namespace.revision, ops: [{ op: 'set', path: plan.path, value: plan.value }] })) } catch { return reject(res, 502, `model update failed for provider ${body.provider}`) }
258
+ try { mutation = await apiProxy.settings.mutate(request({ ns: 'llm-pi-ai', expectedRevision: eligible.provider.namespace.revision, ops: plan.ops })) } catch { return reject(res, 502, `model update failed for provider ${body.provider}`) }
211
259
  const code = errorCode(mutation)
212
260
  if (code === 'settings-conflict') return reject(res, 409, `model update conflicted for provider ${body.provider}`)
213
261
  if (code === 'settings-rejected') return reject(res, 422, `model update was rejected for provider ${body.provider}`)