@linxin666/dsh-remote-web-ui 0.3.0 → 0.3.1
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.i18n.yaml +2 -2
- package/README.md +6 -1
- package/README.zh.md +1 -1
- package/lib/client.js +38 -24
- package/lib/client.js.map +1 -1
- package/lib/index.js +94 -35
- package/lib/mobile.js +2 -2
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +36 -14
- package/lib/types/mobile/views/WorkspaceView.js +1 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +5 -5
- package/lib/types/paired-model-catalog.d.ts.map +1 -1
- package/lib/types/paired-model-catalog.js +72 -13
- package/package.json +1 -1
- package/src/client/index.ts +31 -13
- package/src/mobile/views/WorkspaceView.test.tsx +5 -5
- package/src/mobile/views/WorkspaceView.tsx +1 -1
- package/src/paired-model-catalog.ts +63 -15
|
@@ -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,
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
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('
|
|
86
|
-
expect(await screen.findByText('
|
|
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('
|
|
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('
|
|
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('
|
|
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,
|
|
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) {
|
|
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 =>
|
|
110
|
-
const index = next.findIndex(entry =>
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
124
|
-
|
|
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
|
|
174
|
+
if (group === undefined)
|
|
129
175
|
return undefined;
|
|
130
|
-
|
|
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:
|
|
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.
|
|
4
|
+
"version": "0.3.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
package/src/client/index.ts
CHANGED
|
@@ -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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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('
|
|
104
|
-
expect(await screen.findByText('
|
|
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('
|
|
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('
|
|
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('
|
|
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"
|
|
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 {
|
|
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
|
|
121
|
-
const
|
|
122
|
-
|
|
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
|
|
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): {
|
|
129
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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:
|
|
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}`)
|