@linxin666/dsh-client-ui-task-board 0.1.20 → 0.2.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 +83 -105
- package/README.zh.md +83 -105
- package/lib/client.js +902 -633
- package/lib/client.js.map +1 -1
- package/lib/index.js +2274 -5
- package/lib/types/client/PluginSettingsCard.d.ts +29 -1
- package/lib/types/client/TaskBoardSettingsCard.d.ts +7 -4
- package/lib/types/client/board/TaskCard.d.ts +5 -2
- package/lib/types/client/host-api.d.ts +18 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +8 -10
- package/lib/types/core/controller.d.ts +47 -15
- package/lib/types/core/schedule.d.ts +11 -2
- package/lib/types/core/tasks.d.ts +6 -6
- package/lib/types/core/use-cases/task-archive.d.ts +5 -4
- package/lib/types/core/use-cases/task-schedule.d.ts +3 -3
- package/lib/types/dsh-home.d.ts +16 -0
- package/lib/types/host-ledger.d.ts +52 -0
- package/lib/types/host-routes.d.ts +26 -0
- package/lib/types/host-runner.d.ts +41 -0
- package/lib/types/host-service.d.ts +45 -0
- package/lib/types/index.d.ts +17 -9
- package/lib/types/loopback.d.ts +24 -0
- package/lib/types/power-inhibitor.d.ts +73 -0
- package/lib/types/protocol.d.ts +78 -0
- package/package.json +12 -9
- package/src/client/PluginSettingsCard.tsx +229 -18
- package/src/client/TaskBoardSettingsCard.tsx +51 -4
- package/src/client/board/NewTaskModal.tsx +9 -6
- package/src/client/board/TaskBoard.tsx +24 -7
- package/src/client/board/TaskCard.tsx +25 -8
- package/src/client/board/TaskDetail.tsx +83 -50
- package/src/client/board-mount.tsx +1 -0
- package/src/client/host-api.ts +104 -0
- package/src/client/index.ts +9 -78
- package/src/client/locales.ts +24 -4
- package/src/client/settings-card.module.css +90 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +24 -144
- package/src/core/controller.ts +222 -24
- package/src/core/schedule.ts +77 -13
- package/src/core/store.ts +4 -8
- package/src/core/tasks.ts +6 -6
- package/src/core/use-cases/task-archive.ts +14 -5
- package/src/core/use-cases/task-schedule.ts +6 -5
- package/src/dsh-home.ts +36 -0
- package/src/host-ledger.ts +539 -0
- package/src/host-routes.ts +196 -0
- package/src/host-runner.ts +152 -0
- package/src/host-service.ts +226 -0
- package/src/index.ts +54 -11
- package/src/loopback.ts +63 -0
- package/src/power-inhibitor.ts +321 -0
- package/src/protocol.ts +220 -0
- package/lib/types/core/scheduler.d.ts +0 -70
- package/src/core/scheduler.ts +0 -161
package/lib/types/index.d.ts
CHANGED
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Host loader entry for the task-board plugin.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* The section registers while this plugin is in the host composition (mount /
|
|
8
|
-
* DSH restart) and disappears when the plugin leaves it (unmount / restart),
|
|
9
|
-
* so agents always know the board exists and how to cooperate with it. The
|
|
10
|
-
* announcement can be turned off through the web settings plugin-configuration
|
|
11
|
-
* surface (`announceToAgent`); the section then disappears live.
|
|
4
|
+
* The Host owns the v2 ledger, action API, cron scheduler, session runner,
|
|
5
|
+
* execution reconciliation, and optional idle-sleep inhibitor. The browser is
|
|
6
|
+
* a same-origin asynchronous view over that service.
|
|
12
7
|
*/
|
|
13
8
|
import type { Context } from '@deepseek-ai/cordis';
|
|
14
9
|
import z from 'schemastery';
|
|
10
|
+
/** Default environment variable holding the authenticated proxy token. */
|
|
11
|
+
export declare const DEFAULT_PROXY_TOKEN_ENV = "DSH_TASK_BOARD_PROXY_TOKEN";
|
|
15
12
|
export declare const inject: string[];
|
|
16
13
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
17
|
-
export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-task-board \u63D2\u4EF6\uFF08DSH Web GUI \u7684\u4EFB\u52A1\u770B\u677F\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300C\u4EFB\u52A1\u770B\u677F\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-task-board\uFF09\u7EDF\u4E00\u7EF4\u62A4\uFF0C\u7ECF\u805A\u5408\u5305 web-ui-all \u4E00\u952E\u5B89\u88C5\u3002\u80FD\u529B\uFF1A\u591A\u5217\u770B\u677F\u7BA1\u7406\u4EFB\u52A1\
|
|
14
|
+
export declare const TASK_BOARD_GUIDANCE = "\u672C\u673A\u5DF2\u5B89\u88C5 dsh-task-board \u63D2\u4EF6\uFF08DSH Web GUI \u7684\u4EFB\u52A1\u770B\u677F\uFF09\uFF1A\u4FA7\u8FB9\u680F\u300C\u4EFB\u52A1\u770B\u677F\u300D\u5165\u53E3\uFF1B\u5728 dsh-web-ui \u63D2\u4EF6\u5168\u5BB6\u6876\u4ED3\u5E93\uFF08packages/dsh-task-board\uFF09\u7EDF\u4E00\u7EF4\u62A4\uFF0C\u7ECF\u805A\u5408\u5305 web-ui-all \u4E00\u952E\u5B89\u88C5\u3002\u80FD\u529B\uFF1A\u591A\u5217\u770B\u677F\u7BA1\u7406\u4EFB\u52A1\uFF1BHost \u6743\u5A01\u8D26\u672C\uFF1B\u5173\u95ED\u6D4F\u89C8\u5668\u540E\u4ECD\u7531 Host \u6267\u884C\u548C\u7ED3\u7B97\uFF1B\u4EFB\u52A1\u53EF\u9489\u4F4F\u5DE5\u4F5C\u533A\u3001agent \u9884\u8BBE\u548C\u6743\u9650\uFF1B\u652F\u6301 Host \u672C\u5730\u65F6\u533A\u7684 5 \u6BB5 cron\uFF0C\u9519\u8FC7\u7684\u89E6\u53D1\u70B9\u4E0D\u8865\u8DD1\uFF1B\u53EF\u9009\u4E14\u9ED8\u8BA4\u5173\u95ED\u7684\u7A7A\u95F2\u7CFB\u7EDF\u7761\u7720\u4FDD\u62A4\u5141\u8BB8\u5C4F\u5E55\u7184\u706D\uFF0C\u4F46\u4E0D\u627F\u8BFA\u62E6\u622A\u5408\u76D6\u3001\u624B\u52A8\u7761\u7720\u3001\u4F11\u7720\u3001\u5173\u673A\u6216\u5524\u9192\u5DF2\u7761\u7720\u673A\u5668\u3002\u6267\u884C\u6D88\u8017 API \u989D\u5EA6\u3002\u7528\u6237\u63D0\u5230\u300C\u4EFB\u52A1\u770B\u677F / \u770B\u677F / \u5B9A\u65F6\u4EFB\u52A1\u300D\u65F6\u5373\u6307\u672C\u63D2\u4EF6\uFF0C\u8BF7\u636E\u6B64\u534F\u4F5C\u3002";
|
|
18
15
|
/**
|
|
19
16
|
* Settings namespace of the board's announcement capability — the section the
|
|
20
17
|
* web settings surface edits. Spelled here rather than imported: the browser
|
|
@@ -31,8 +28,19 @@ export interface Config {
|
|
|
31
28
|
announceToAgent?: boolean;
|
|
32
29
|
/** Master switch for the plugin (browser half + host announcement). */
|
|
33
30
|
enabled?: boolean;
|
|
31
|
+
/** Prevent idle system sleep while sessions run or schedules are armed. */
|
|
32
|
+
preventIdleSleep?: boolean;
|
|
33
|
+
/** Canonical reverse-proxy Host authorities admitted with a server-side token. */
|
|
34
|
+
trustedProxyHosts?: string[];
|
|
35
|
+
/** Environment variable whose value the authenticated proxy injects upstream. */
|
|
36
|
+
proxyTokenEnv?: string;
|
|
34
37
|
}
|
|
35
38
|
export declare const Config: z<Config>;
|
|
39
|
+
/** Resolve proxy access without ever placing the token value in plugin config. */
|
|
40
|
+
export declare function resolveProxyAccess(config: Config | undefined, env?: NodeJS.ProcessEnv): {
|
|
41
|
+
trustedProxyHosts: string[];
|
|
42
|
+
proxyToken?: string;
|
|
43
|
+
};
|
|
36
44
|
/**
|
|
37
45
|
* Register the board's announcement section, gated on the composition entry's
|
|
38
46
|
* `announceToAgent` (and the live settings value once the web settings
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback trust fence shared by the host route families: socket address,
|
|
3
|
+
* Host header, and browser same-origin markers. Packages receive this file as
|
|
4
|
+
* a generated copy via scripts/sync-shared.mjs; edit the shared source and
|
|
5
|
+
* re-run the sync instead of editing a copy.
|
|
6
|
+
*
|
|
7
|
+
* Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
|
|
8
|
+
* remote-web-ui gate), localhost hostnames, plus the browser same-origin
|
|
9
|
+
* markers (sec-fetch-site and Origin) for the request-level fence.
|
|
10
|
+
* @module dsh-web-ui-shared/host/loopback
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage } from 'node:http';
|
|
13
|
+
/** IPv4 127/8 predicate (four decimal octets, first == 127). */
|
|
14
|
+
export declare function isIPv4Loopback(v4: string): boolean;
|
|
15
|
+
/** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
|
|
16
|
+
export declare function isLoopbackAddress(address: string | undefined): boolean;
|
|
17
|
+
/** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
|
|
18
|
+
export declare function isLoopbackHostname(hostname: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Request-level trust fence: a loopback socket address AND a loopback Host
|
|
21
|
+
* header, plus browser same-origin markers. The socket address is
|
|
22
|
+
* authoritative; X-Forwarded-For is never trusted.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isLoopbackRequest(request: IncomingMessage): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type ChildProcess, type SpawnSyncReturns } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import type { TaskBoardPowerSnapshot } from './protocol.ts';
|
|
4
|
+
declare const WINDOWS_HELPER: string;
|
|
5
|
+
declare const LINUX_HELPER: string;
|
|
6
|
+
declare const LINUX_INHIBIT_PATHS: readonly ["/usr/bin/systemd-inhibit", "/bin/systemd-inhibit"];
|
|
7
|
+
export interface PowerReasons {
|
|
8
|
+
runningSessions: number;
|
|
9
|
+
armedSchedules: number;
|
|
10
|
+
sessionStateKnown: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface SpawnOptions {
|
|
13
|
+
shell: false;
|
|
14
|
+
windowsHide: boolean;
|
|
15
|
+
stdio: ['pipe', 'pipe', 'pipe'] | ['ignore', 'ignore', 'ignore'];
|
|
16
|
+
}
|
|
17
|
+
export type SpawnLike = (file: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
|
|
18
|
+
export type SpawnSyncLike = (file: string, args: readonly string[], options: {
|
|
19
|
+
stdio: 'ignore';
|
|
20
|
+
timeout: number;
|
|
21
|
+
windowsHide: boolean;
|
|
22
|
+
}) => Pick<SpawnSyncReturns<Buffer>, 'status' | 'error'>;
|
|
23
|
+
export interface PowerInhibitorOptions {
|
|
24
|
+
platform?: NodeJS.Platform;
|
|
25
|
+
pid?: number;
|
|
26
|
+
env?: NodeJS.ProcessEnv;
|
|
27
|
+
spawn?: SpawnLike;
|
|
28
|
+
spawnSync?: SpawnSyncLike;
|
|
29
|
+
exists?: typeof existsSync;
|
|
30
|
+
execPath?: string;
|
|
31
|
+
setTimeout?: typeof globalThis.setTimeout;
|
|
32
|
+
clearTimeout?: typeof globalThis.clearTimeout;
|
|
33
|
+
}
|
|
34
|
+
export declare class PowerInhibitor {
|
|
35
|
+
private readonly listeners;
|
|
36
|
+
private enabled;
|
|
37
|
+
private reasons;
|
|
38
|
+
private phase;
|
|
39
|
+
private child;
|
|
40
|
+
private retry;
|
|
41
|
+
private retryReset;
|
|
42
|
+
private retryIndex;
|
|
43
|
+
private lastError;
|
|
44
|
+
private stopping;
|
|
45
|
+
private readonly platform;
|
|
46
|
+
private readonly pid;
|
|
47
|
+
private readonly env;
|
|
48
|
+
private readonly spawn;
|
|
49
|
+
private readonly spawnSync;
|
|
50
|
+
private readonly exists;
|
|
51
|
+
private readonly execPath;
|
|
52
|
+
private readonly timer;
|
|
53
|
+
private readonly clearTimer;
|
|
54
|
+
private linuxProbe;
|
|
55
|
+
constructor(options?: PowerInhibitorOptions);
|
|
56
|
+
setEnabled(enabled: boolean): void;
|
|
57
|
+
updateReasons(reasons: PowerReasons): void;
|
|
58
|
+
subscribe(listener: () => void): () => void;
|
|
59
|
+
snapshot(): TaskBoardPowerSnapshot;
|
|
60
|
+
dispose(): void;
|
|
61
|
+
private desired;
|
|
62
|
+
private sync;
|
|
63
|
+
private acquire;
|
|
64
|
+
private markReady;
|
|
65
|
+
private fail;
|
|
66
|
+
private release;
|
|
67
|
+
private clearRetryReset;
|
|
68
|
+
private windowsPowerShell;
|
|
69
|
+
private linuxSystemdInhibit;
|
|
70
|
+
private spawnCommand;
|
|
71
|
+
private emit;
|
|
72
|
+
}
|
|
73
|
+
export { LINUX_HELPER, LINUX_INHIBIT_PATHS, WINDOWS_HELPER };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { TaskUpdatePatch } from './core/use-cases/task-update.ts';
|
|
2
|
+
import { type NewTaskInput, type TaskRecord, type TaskStatus } from './core/tasks.ts';
|
|
3
|
+
export declare const TASK_BOARD_SCHEMA_VERSION: 2;
|
|
4
|
+
export declare const TASK_BOARD_API_PREFIX = "/api/task-board";
|
|
5
|
+
export type PowerPhase = 'disabled' | 'idle' | 'acquiring' | 'active' | 'error' | 'unsupported';
|
|
6
|
+
export interface TaskBoardPowerSnapshot {
|
|
7
|
+
platform: string;
|
|
8
|
+
phase: PowerPhase;
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
runningSessions: number;
|
|
11
|
+
armedSchedules: number;
|
|
12
|
+
sessionStateKnown: boolean;
|
|
13
|
+
lastError?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface TaskBoardSchedulerSnapshot {
|
|
16
|
+
timeZone: string;
|
|
17
|
+
/** Opaque identity of the current Host ledger generation. */
|
|
18
|
+
ledgerId?: string;
|
|
19
|
+
lastTickAt?: number;
|
|
20
|
+
error?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface TaskBoardSnapshot {
|
|
23
|
+
schemaVersion: typeof TASK_BOARD_SCHEMA_VERSION;
|
|
24
|
+
revision: number;
|
|
25
|
+
tasks: TaskRecord[];
|
|
26
|
+
scheduler: TaskBoardSchedulerSnapshot;
|
|
27
|
+
power: TaskBoardPowerSnapshot;
|
|
28
|
+
}
|
|
29
|
+
/** SSE event frame: revision/scheduler/power only, never the task list. */
|
|
30
|
+
export interface TaskBoardEventPayload {
|
|
31
|
+
revision: number;
|
|
32
|
+
scheduler: TaskBoardSchedulerSnapshot;
|
|
33
|
+
power: TaskBoardPowerSnapshot;
|
|
34
|
+
}
|
|
35
|
+
export type TaskBoardAction = {
|
|
36
|
+
kind: 'import';
|
|
37
|
+
sourceId: string;
|
|
38
|
+
tasks: TaskRecord[];
|
|
39
|
+
} | {
|
|
40
|
+
kind: 'create';
|
|
41
|
+
id: string;
|
|
42
|
+
input: NewTaskInput;
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'update';
|
|
45
|
+
taskId: string;
|
|
46
|
+
patch: TaskUpdatePatch;
|
|
47
|
+
} | {
|
|
48
|
+
kind: 'delete';
|
|
49
|
+
taskId: string;
|
|
50
|
+
} | {
|
|
51
|
+
kind: 'move';
|
|
52
|
+
taskId: string;
|
|
53
|
+
status: TaskStatus;
|
|
54
|
+
} | {
|
|
55
|
+
kind: 'archive';
|
|
56
|
+
taskId: string;
|
|
57
|
+
} | {
|
|
58
|
+
kind: 'restore';
|
|
59
|
+
taskId: string;
|
|
60
|
+
} | {
|
|
61
|
+
kind: 'set-schedule';
|
|
62
|
+
taskId: string;
|
|
63
|
+
patch: {
|
|
64
|
+
enabled?: boolean;
|
|
65
|
+
cron?: string;
|
|
66
|
+
};
|
|
67
|
+
} | {
|
|
68
|
+
kind: 'run';
|
|
69
|
+
taskId: string;
|
|
70
|
+
} | {
|
|
71
|
+
kind: 'rerun';
|
|
72
|
+
taskId: string;
|
|
73
|
+
};
|
|
74
|
+
export interface TaskBoardActionEnvelope {
|
|
75
|
+
requestId: string;
|
|
76
|
+
action: TaskBoardAction;
|
|
77
|
+
}
|
|
78
|
+
export declare function parseActionEnvelope(value: unknown): TaskBoardActionEnvelope | undefined;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-client-ui-task-board",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "0.1
|
|
3
|
+
"description": "Host-authoritative task board for the DSH Web GUI with real session execution, Host cron scheduling, and optional cross-platform idle-sleep protection; mounted without DSH source changes.",
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -42,15 +42,18 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
45
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.
|
|
46
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.
|
|
47
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.
|
|
48
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.
|
|
49
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.
|
|
50
|
-
"@deepseek-ai/dsh-
|
|
51
|
-
"@deepseek-ai/dsh-
|
|
45
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.0-rc.7",
|
|
46
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
|
|
47
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
|
|
48
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
|
|
50
|
+
"@deepseek-ai/dsh-host-apiproxy": "^0.1.0-rc.7",
|
|
51
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
|
|
52
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
|
|
53
|
+
"@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.7",
|
|
52
54
|
"@types/react": "~18.3.1",
|
|
53
55
|
"@types/react-dom": "^18.3.5",
|
|
56
|
+
"@types/node": "^22.20.0",
|
|
54
57
|
"jsdom": "^25.0.0",
|
|
55
58
|
"lightningcss": "1.32.0",
|
|
56
59
|
"react": "^18.3.1",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* ui-plugin-config PluginCard in a self-contained slice.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { useState, type ReactNode } from 'react'
|
|
11
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
12
12
|
import type { CardShell } from './settings-form.ts'
|
|
13
13
|
import css from './settings-card.module.css'
|
|
14
14
|
|
|
@@ -56,6 +56,12 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
56
56
|
* already provides the selection.
|
|
57
57
|
*/
|
|
58
58
|
alwaysOpen?: boolean
|
|
59
|
+
/**
|
|
60
|
+
* Hide the save/discard footer: cards whose body applies its own changes
|
|
61
|
+
* immediately (an embedded external settings section) have no staged
|
|
62
|
+
* edits, so the footer would sit there permanently disabled.
|
|
63
|
+
*/
|
|
64
|
+
hideFooter?: boolean
|
|
59
65
|
/** The plugin's controls. */
|
|
60
66
|
children: ReactNode
|
|
61
67
|
}
|
|
@@ -140,6 +146,9 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
140
146
|
<div className={css.body}>
|
|
141
147
|
{!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
|
|
142
148
|
{props.children}
|
|
149
|
+
{props.hideFooter === true
|
|
150
|
+
? null
|
|
151
|
+
: (
|
|
143
152
|
<div className={css.footer}>
|
|
144
153
|
{state.failed
|
|
145
154
|
? (
|
|
@@ -165,6 +174,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
165
174
|
{props.t(!state.saving ? 'settings.save' : 'settings.saving')}
|
|
166
175
|
</button>
|
|
167
176
|
</div>
|
|
177
|
+
)}
|
|
168
178
|
</div>
|
|
169
179
|
)
|
|
170
180
|
: null}
|
|
@@ -245,6 +255,210 @@ export function ValueField(props: FieldProps & {
|
|
|
245
255
|
)
|
|
246
256
|
}
|
|
247
257
|
|
|
258
|
+
const NON_SKIN_BODY_MARKERS = new Set(['dshSkinCenter', 'dshSidebarCollapsed'])
|
|
259
|
+
// 检测使用使用皮肤,用了皮肤退回原生select样式,防止样式冲突。默认外观下使用优化后的select样式。
|
|
260
|
+
function isSkinActive(): boolean {
|
|
261
|
+
const datasetList = Object.keys(document.body.dataset)
|
|
262
|
+
const isActive = datasetList.some(key => key.startsWith('dsh') && !NON_SKIN_BODY_MARKERS.has(key))
|
|
263
|
+
return isActive
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
interface SelectOption {
|
|
267
|
+
value: string
|
|
268
|
+
label: string
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const SELECT_CLOSE_MS = 100
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The shared dual-mode select control. While an appearance skin is active it
|
|
275
|
+
* renders the legacy native `<select>` untouched, so element-level skin
|
|
276
|
+
* selectors keep working; under the default appearance it renders a
|
|
277
|
+
* self-drawn `role="listbox"` popup whose open/close is transition-animated.
|
|
278
|
+
* Staged cards reach it through BooleanField/ChoiceField; immediate-apply
|
|
279
|
+
* editors (the side-card prefs) bind it directly through onEdit.
|
|
280
|
+
* 双模式下拉框:皮肤激活时用原生 select,默认外观用自绘动画弹层。
|
|
281
|
+
*/
|
|
282
|
+
export function SelectField(props: {
|
|
283
|
+
id: string
|
|
284
|
+
options: ReadonlyArray<SelectOption>
|
|
285
|
+
value: string
|
|
286
|
+
disabled: boolean
|
|
287
|
+
invalid: boolean
|
|
288
|
+
onEdit: (text: string) => void
|
|
289
|
+
}) {
|
|
290
|
+
const { id, options, value } = props
|
|
291
|
+
const [open, setOpen] = useState(false)
|
|
292
|
+
const [closing, setClosing] = useState(false)
|
|
293
|
+
const [phase, setPhase] = useState<'initial' | 'open'>('initial')
|
|
294
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
295
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
296
|
+
const wrapRef = useRef<HTMLDivElement | null>(null)
|
|
297
|
+
const popupRef = useRef<HTMLDivElement | null>(null)
|
|
298
|
+
|
|
299
|
+
const currentIndex = () => {
|
|
300
|
+
const index = options.findIndex(option => option.value === value)
|
|
301
|
+
return index >= 0 ? index : 0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const close = useCallback(() => {
|
|
305
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
306
|
+
setClosing(true)
|
|
307
|
+
closeTimer.current = setTimeout(() => {
|
|
308
|
+
setClosing(false)
|
|
309
|
+
setOpen(false)
|
|
310
|
+
}, SELECT_CLOSE_MS)
|
|
311
|
+
}, [])
|
|
312
|
+
|
|
313
|
+
const openPopup = () => {
|
|
314
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
315
|
+
setActiveIndex(currentIndex())
|
|
316
|
+
setPhase('initial')
|
|
317
|
+
setClosing(false)
|
|
318
|
+
setOpen(true)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const commit = (index: number) => {
|
|
322
|
+
const option = options[index]
|
|
323
|
+
if (option) props.onEdit(option.value)
|
|
324
|
+
close()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const onTriggerClick = () => {
|
|
328
|
+
if (props.disabled) return
|
|
329
|
+
if (open && !closing) close()
|
|
330
|
+
else openPopup()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
334
|
+
if (props.disabled) return
|
|
335
|
+
const count = options.length
|
|
336
|
+
switch (event.key) {
|
|
337
|
+
case 'ArrowDown':
|
|
338
|
+
case 'ArrowUp':
|
|
339
|
+
case 'Enter':
|
|
340
|
+
case ' ':
|
|
341
|
+
event.preventDefault()
|
|
342
|
+
if (!open) {
|
|
343
|
+
openPopup()
|
|
344
|
+
} else if (!closing) {
|
|
345
|
+
if (event.key === 'ArrowDown') setActiveIndex(index => (index + 1) % count)
|
|
346
|
+
else if (event.key === 'ArrowUp') setActiveIndex(index => (index - 1 + count) % count)
|
|
347
|
+
else commit(activeIndex)
|
|
348
|
+
}
|
|
349
|
+
break
|
|
350
|
+
case 'Escape':
|
|
351
|
+
if (open) {
|
|
352
|
+
event.preventDefault()
|
|
353
|
+
event.stopPropagation()
|
|
354
|
+
close()
|
|
355
|
+
}
|
|
356
|
+
break
|
|
357
|
+
case 'Tab':
|
|
358
|
+
if (open) close()
|
|
359
|
+
break
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
useEffect(() => () => {
|
|
364
|
+
if (closeTimer.current !== undefined) clearTimeout(closeTimer.current)
|
|
365
|
+
}, [])
|
|
366
|
+
useLayoutEffect(() => {
|
|
367
|
+
if (open && !closing && phase === 'initial') {
|
|
368
|
+
void popupRef.current?.offsetHeight
|
|
369
|
+
setPhase('open')
|
|
370
|
+
}
|
|
371
|
+
}, [open, closing, phase])
|
|
372
|
+
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!open) return
|
|
375
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
376
|
+
const target = event.target
|
|
377
|
+
if (target instanceof Node && !wrapRef.current?.contains(target)) close()
|
|
378
|
+
}
|
|
379
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
380
|
+
return () => document.removeEventListener('pointerdown', onPointerDown)
|
|
381
|
+
}, [open, close])
|
|
382
|
+
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
if (props.disabled && open) close()
|
|
385
|
+
}, [props.disabled, open, close])
|
|
386
|
+
|
|
387
|
+
// 使用了皮肤则退回原生select样式防止样式冲突。
|
|
388
|
+
if (isSkinActive()) {
|
|
389
|
+
return (
|
|
390
|
+
<select
|
|
391
|
+
id={id}
|
|
392
|
+
className={css.select}
|
|
393
|
+
value={value}
|
|
394
|
+
disabled={props.disabled}
|
|
395
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
396
|
+
>
|
|
397
|
+
{options.map(option => (
|
|
398
|
+
<option key={option.value} value={option.value}>{option.label}</option>
|
|
399
|
+
))}
|
|
400
|
+
</select>
|
|
401
|
+
)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const label = options.find(option => option.value === value)?.label ?? ''
|
|
405
|
+
const popupClass = closing
|
|
406
|
+
? `${css.selectPopup} ${css.selectPopupClose}`
|
|
407
|
+
: phase === 'open'
|
|
408
|
+
? `${css.selectPopup} ${css.selectPopupOpen}`
|
|
409
|
+
: css.selectPopup
|
|
410
|
+
return (
|
|
411
|
+
<div className={css.selectWrap} ref={wrapRef}>
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
id={id}
|
|
415
|
+
className={`${css.select} ${css.selectButton}`}
|
|
416
|
+
disabled={props.disabled}
|
|
417
|
+
aria-haspopup="listbox"
|
|
418
|
+
aria-expanded={open}
|
|
419
|
+
aria-activedescendant={open ? `${id}-o${activeIndex}` : undefined}
|
|
420
|
+
aria-invalid={props.invalid || undefined}
|
|
421
|
+
onClick={onTriggerClick}
|
|
422
|
+
onKeyDown={onKeyDown}
|
|
423
|
+
>
|
|
424
|
+
<span className={css.selectLabel}>{label}</span>
|
|
425
|
+
<svg
|
|
426
|
+
width="14"
|
|
427
|
+
height="14"
|
|
428
|
+
viewBox="0 0 14 14"
|
|
429
|
+
fill="none"
|
|
430
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
431
|
+
className={open ? `${css.selectChevron} ${css.selectChevronOpen}` : css.selectChevron}
|
|
432
|
+
aria-hidden="true"
|
|
433
|
+
>
|
|
434
|
+
<path
|
|
435
|
+
d="M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z"
|
|
436
|
+
fill="currentColor"
|
|
437
|
+
/>
|
|
438
|
+
</svg>
|
|
439
|
+
</button>
|
|
440
|
+
{open
|
|
441
|
+
? (
|
|
442
|
+
<div className={popupClass} role="listbox" ref={popupRef}>
|
|
443
|
+
{options.map((option, index) => (
|
|
444
|
+
<div
|
|
445
|
+
key={option.value}
|
|
446
|
+
id={`${id}-o${index}`}
|
|
447
|
+
role="option"
|
|
448
|
+
aria-selected={option.value === value}
|
|
449
|
+
className={`${css.selectOption}${option.value === value ? ` ${css.selectOptionSelected}` : ''}${index === activeIndex && !closing ? ` ${css.selectOptionActive}` : ''}`}
|
|
450
|
+
onClick={() => { commit(index) }}
|
|
451
|
+
>
|
|
452
|
+
{option.label}
|
|
453
|
+
</div>
|
|
454
|
+
))}
|
|
455
|
+
</div>
|
|
456
|
+
)
|
|
457
|
+
: null}
|
|
458
|
+
</div>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
|
|
248
462
|
/** A staged boolean field: 继承 / 开 / 关. */
|
|
249
463
|
export function BooleanField(props: FieldProps & {
|
|
250
464
|
/** Copy for the inherit option. */
|
|
@@ -274,17 +488,18 @@ export function BooleanField(props: FieldProps & {
|
|
|
274
488
|
)
|
|
275
489
|
: null}
|
|
276
490
|
</div>
|
|
277
|
-
<
|
|
491
|
+
<SelectField
|
|
278
492
|
id={props.id}
|
|
279
|
-
|
|
493
|
+
options={[
|
|
494
|
+
{ value: '', label: props.inheritLabel },
|
|
495
|
+
{ value: 'true', label: props.onLabel },
|
|
496
|
+
{ value: 'false', label: props.offLabel },
|
|
497
|
+
]}
|
|
280
498
|
value={props.text}
|
|
281
499
|
disabled={props.disabled}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
<option value="true">{props.onLabel}</option>
|
|
286
|
-
<option value="false">{props.offLabel}</option>
|
|
287
|
-
</select>
|
|
500
|
+
invalid={props.invalid}
|
|
501
|
+
onEdit={props.onEdit}
|
|
502
|
+
/>
|
|
288
503
|
<p className={css.hint}>{props.hint}</p>
|
|
289
504
|
</div>
|
|
290
505
|
)
|
|
@@ -317,18 +532,14 @@ export function ChoiceField(props: FieldProps & {
|
|
|
317
532
|
)
|
|
318
533
|
: null}
|
|
319
534
|
</div>
|
|
320
|
-
<
|
|
535
|
+
<SelectField
|
|
321
536
|
id={props.id}
|
|
322
|
-
|
|
537
|
+
options={[{ value: '', label: props.inheritLabel }, ...props.choices]}
|
|
323
538
|
value={props.text}
|
|
324
539
|
disabled={props.disabled}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
{props.choices.map(choice => (
|
|
329
|
-
<option key={choice.value} value={choice.value}>{choice.label}</option>
|
|
330
|
-
))}
|
|
331
|
-
</select>
|
|
540
|
+
invalid={props.invalid}
|
|
541
|
+
onEdit={props.onEdit}
|
|
542
|
+
/>
|
|
332
543
|
<p className={props.invalid ? css.invalid : css.hint}>
|
|
333
544
|
{props.invalid ? props.invalidLabel : props.hint}
|
|
334
545
|
</p>
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* plugin
|
|
5
|
-
* namespace.
|
|
2
|
+
* Task-board settings for availability, agent announcement, and optional Host
|
|
3
|
+
* idle-sleep protection. Registers into the `web-ui.plugin.item` child slot
|
|
4
|
+
* the Web UI plugin group renders, bound to the `task-board` namespace.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
9
8
|
import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
9
|
+
import { useEffect, useState } from 'react'
|
|
10
|
+
import type { TaskBoardPowerSnapshot } from '../protocol.ts'
|
|
10
11
|
import { PluginSettingsCard, BooleanField } from './PluginSettingsCard.tsx'
|
|
11
12
|
import { CardForm, booleanField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
|
|
12
13
|
|
|
@@ -16,6 +17,8 @@ export interface TaskBoardSettings {
|
|
|
16
17
|
enabled?: boolean
|
|
17
18
|
/** Whether the board announces itself in every agent's system prompt. */
|
|
18
19
|
announceToAgent?: boolean
|
|
20
|
+
/** Prevent host idle sleep while sessions run or schedules are armed. */
|
|
21
|
+
preventIdleSleep?: boolean
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/** What the task-board card renders. */
|
|
@@ -24,6 +27,8 @@ export interface TaskBoardSettingsCardState extends CardShell {
|
|
|
24
27
|
enabled: CardFieldState
|
|
25
28
|
/** System-prompt announcement flag. */
|
|
26
29
|
announceToAgent: CardFieldState
|
|
30
|
+
/** Idle-system-sleep protection flag. */
|
|
31
|
+
preventIdleSleep: CardFieldState
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** The registration-side face the card's slot entry injects. */
|
|
@@ -44,6 +49,7 @@ export class TaskBoardSettingsCardController {
|
|
|
44
49
|
this.form = new CardForm(scope, [
|
|
45
50
|
booleanField('enabled'),
|
|
46
51
|
booleanField('announceToAgent'),
|
|
52
|
+
booleanField('preventIdleSleep'),
|
|
47
53
|
])
|
|
48
54
|
this.store = this.form.bind(() => this.projection())
|
|
49
55
|
}
|
|
@@ -53,6 +59,7 @@ export class TaskBoardSettingsCardController {
|
|
|
53
59
|
...this.form.shell(),
|
|
54
60
|
enabled: this.form.field('enabled'),
|
|
55
61
|
announceToAgent: this.form.field('announceToAgent'),
|
|
62
|
+
preventIdleSleep: this.form.field('preventIdleSleep'),
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
|
|
@@ -88,6 +95,23 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
|
|
|
88
95
|
const { t } = props
|
|
89
96
|
const state = props.useTaskBoardSettingsCard(snapshot => snapshot)
|
|
90
97
|
const disabled = !state.writable
|
|
98
|
+
const [power, setPower] = useState<TaskBoardPowerSnapshot | undefined>()
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
// The SSE channel already carries power on every real change and pushes
|
|
101
|
+
// one frame on subscribe; polling the full /state snapshot every 5 s
|
|
102
|
+
// re-cloned and re-serialized the whole ledger server-side for one field.
|
|
103
|
+
let live = true
|
|
104
|
+
const events = new EventSource('/api/task-board/events')
|
|
105
|
+
events.onmessage = (message: MessageEvent<string>): void => {
|
|
106
|
+
try {
|
|
107
|
+
const frame = JSON.parse(message.data) as { power?: TaskBoardPowerSnapshot }
|
|
108
|
+
if (frame.power !== undefined && live) setPower(frame.power)
|
|
109
|
+
} catch {
|
|
110
|
+
// The settings form remains usable while the host status is reconnecting.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return () => { live = false; events.close() }
|
|
114
|
+
}, [])
|
|
91
115
|
const fieldProps = {
|
|
92
116
|
overriddenLabel: t('settings.overridden'),
|
|
93
117
|
resetLabel: t('settings.reset'),
|
|
@@ -99,6 +123,7 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
|
|
|
99
123
|
t={t}
|
|
100
124
|
titleKey="settings.title"
|
|
101
125
|
descriptionKey="settings.description"
|
|
126
|
+
defaultOpen={false}
|
|
102
127
|
state={state}
|
|
103
128
|
onSave={props.save}
|
|
104
129
|
onDiscard={props.discard}
|
|
@@ -127,6 +152,28 @@ export function TaskBoardSettingsCard(props: TaskBoardSettingsCardProps) {
|
|
|
127
152
|
onEdit={(text) => { props.edit('announceToAgent', text) }}
|
|
128
153
|
onReset={() => { props.resetField('announceToAgent') }}
|
|
129
154
|
/>
|
|
155
|
+
<BooleanField
|
|
156
|
+
id="settings-task-board-prevent-idle-sleep"
|
|
157
|
+
label={t('settings.preventIdleSleep')}
|
|
158
|
+
hint={t('settings.preventIdleSleepHint')}
|
|
159
|
+
inheritLabel={t('settings.inherit')}
|
|
160
|
+
onLabel={t('settings.on')}
|
|
161
|
+
offLabel={t('settings.off')}
|
|
162
|
+
{...fieldProps}
|
|
163
|
+
{...state.preventIdleSleep}
|
|
164
|
+
onEdit={(text) => { props.edit('preventIdleSleep', text) }}
|
|
165
|
+
onReset={() => { props.resetField('preventIdleSleep') }}
|
|
166
|
+
/>
|
|
167
|
+
<p>
|
|
168
|
+
{t('settings.powerStatus', {
|
|
169
|
+
platform: power?.platform ?? t('settings.powerUnknown'),
|
|
170
|
+
phase: power?.phase ?? t('settings.powerUnknown'),
|
|
171
|
+
running: String(power?.runningSessions ?? 0),
|
|
172
|
+
schedules: String(power?.armedSchedules ?? 0),
|
|
173
|
+
})}
|
|
174
|
+
</p>
|
|
175
|
+
<p>{t('settings.powerBoundary')}</p>
|
|
176
|
+
{power?.lastError !== undefined && <p>{t('settings.powerError', { error: power.lastError })}</p>}
|
|
130
177
|
</PluginSettingsCard>
|
|
131
178
|
)
|
|
132
179
|
}
|