dsh-blackjack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/blackjack.cordis.yml +13 -0
- package/dist/adapter.d.ts +33 -0
- package/dist/adapter.js +58 -0
- package/dist/api.d.ts +85 -0
- package/dist/api.js +55 -0
- package/dist/client/Table.d.ts +30 -0
- package/dist/client/Table.js +74 -0
- package/dist/client/index.d.ts +12 -0
- package/dist/client/index.js +68 -0
- package/dist/client.js +26 -0
- package/dist/commands.d.ts +17 -0
- package/dist/commands.js +262 -0
- package/dist/config.d.ts +13 -0
- package/dist/config.js +16 -0
- package/dist/identity.d.ts +12 -0
- package/dist/identity.js +28 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +39 -0
- package/dist/render.d.ts +16 -0
- package/dist/render.js +70 -0
- package/dist/router.d.ts +100 -0
- package/dist/router.js +179 -0
- package/dist/routes.d.ts +54 -0
- package/dist/routes.js +195 -0
- package/package.json +95 -0
package/dist/router.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { QUOTA_EXCEEDED_CODE, isQuotaExceededError } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { createPoolAdapter } from './adapter.js';
|
|
3
|
+
/** Provider route the pool adapter is registered under; also the loop-guard for `shouldFallback`. */
|
|
4
|
+
export const POOL_PROVIDER = 'blackjack-pool';
|
|
5
|
+
/** Chunk types that count as "content already reached the consumer" for the no-duplicate-output rule. */
|
|
6
|
+
const CONTENT_TYPES = new Set(['block-start', 'text-delta', 'reasoning-delta', 'tool-call-delta', 'block-end']);
|
|
7
|
+
/** Narrow a finish chunk's `error` failure to whether it reads as quota exhaustion. */
|
|
8
|
+
function isQuotaFailure(failure) {
|
|
9
|
+
// Our own pool adapter (DeepSeekAdapter) already normalizes provider wire
|
|
10
|
+
// errors to the stable QUOTA_EXCEEDED_CODE (see dsh-llm-deepseek's
|
|
11
|
+
// httpErrorCode), so the direct code check is the common case. The
|
|
12
|
+
// isQuotaExceededError(...) fallback — over the same
|
|
13
|
+
// `[code, message].filter(Boolean).join(' ')` shape dsh-llm-deepseek's own
|
|
14
|
+
// httpErrorCode uses — catches a third-party adapter that never normalized
|
|
15
|
+
// its wording into that code at all (e.g. it fell through to `UNKNOWN`
|
|
16
|
+
// carrying a plainly quota-worded message).
|
|
17
|
+
return failure.code === QUOTA_EXCEEDED_CODE
|
|
18
|
+
|| isQuotaExceededError([failure.code, failure.message].filter(Boolean).join(' '));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Decide whether one finished call should be retried on the pool route.
|
|
22
|
+
*
|
|
23
|
+
* NOTE on `QUOTA_EXCEEDED_CODE` vs `isQuotaExceededError`: the brief's
|
|
24
|
+
* original sketch called `isQuotaExceededError(reason.failure)` directly on
|
|
25
|
+
* the failure object. Checking the installed `@deepseek-ai/dsh-llm@0.1.0-rc.6`
|
|
26
|
+
* package directly shows `isQuotaExceededError(detail: string): boolean` is a
|
|
27
|
+
* *raw provider wording* classifier (regexes over free text such as
|
|
28
|
+
* "insufficient quota") — it is what an adapter uses internally while
|
|
29
|
+
* normalizing a thrown provider error into a `LlmFailure`, not something a
|
|
30
|
+
* downstream consumer applies to an already-normalized `finish` chunk as-is.
|
|
31
|
+
* The normalized signal to compare first is `LlmFailure.code` against
|
|
32
|
+
* `QUOTA_EXCEEDED_CODE`; `isQuotaExceededError` is kept only as a secondary,
|
|
33
|
+
* string-joined fallback for adapters that never normalized at all (see
|
|
34
|
+
* `isQuotaFailure` above).
|
|
35
|
+
*/
|
|
36
|
+
export function shouldFallback(input) {
|
|
37
|
+
if (input.provider === POOL_PROVIDER)
|
|
38
|
+
return false;
|
|
39
|
+
if (!input.hasToken || input.emittedContent)
|
|
40
|
+
return false;
|
|
41
|
+
const reason = input.finish.reason;
|
|
42
|
+
if (reason.kind !== 'error')
|
|
43
|
+
return false;
|
|
44
|
+
return isQuotaFailure(reason.failure);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Wrap one model call: pass everything through, and only when the call ends
|
|
48
|
+
* with a content-free quota failure retry it against the pool route. Any
|
|
49
|
+
* failure of that retry surfaces the ORIGINAL failure, so installing this
|
|
50
|
+
* plugin can never make a call worse than not having it.
|
|
51
|
+
*
|
|
52
|
+
* Two invariants the pool-retry branch upholds that are easy to get wrong:
|
|
53
|
+
* - The stream ALWAYS ends with exactly one terminal `finish` chunk. A pool
|
|
54
|
+
* retry that throws mid-stream (after already forwarding content), or one
|
|
55
|
+
* that simply ends without ever sending its own finish, would otherwise
|
|
56
|
+
* leave the consumer's `BlockAssembler` defaulting an absent finish to
|
|
57
|
+
* `{kind: 'stop'}` — silently presenting a FAILED fallback as a successful,
|
|
58
|
+
* truncated turn. `poolFinished` tracks whether the pool ever produced its
|
|
59
|
+
* own terminal finish; whenever it did not, the ORIGINAL failure is yielded
|
|
60
|
+
* as the terminal chunk instead (legal even after open content blocks,
|
|
61
|
+
* since its `reason.kind` is `'error'`).
|
|
62
|
+
* - `usage` never gets forwarded twice. Adapters emit at most one `usage`
|
|
63
|
+
* chunk, just before their finish. The primary attempt's `usage` (if any)
|
|
64
|
+
* is buffered rather than forwarded immediately, so it can be dropped
|
|
65
|
+
* entirely once a fallback is underway — the pool call reports its own —
|
|
66
|
+
* instead of both being forwarded and double-counting tokens.
|
|
67
|
+
*/
|
|
68
|
+
export async function* runWithFallback(options, next, deps) {
|
|
69
|
+
let emittedContent = false;
|
|
70
|
+
let finish;
|
|
71
|
+
const pendingUsage = [];
|
|
72
|
+
for await (const c of next()) {
|
|
73
|
+
if (c.type === 'finish') {
|
|
74
|
+
finish = c;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
if (c.type === 'usage') {
|
|
78
|
+
pendingUsage.push(c);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (CONTENT_TYPES.has(c.type))
|
|
82
|
+
emittedContent = true;
|
|
83
|
+
yield c;
|
|
84
|
+
}
|
|
85
|
+
if (!finish)
|
|
86
|
+
return;
|
|
87
|
+
// Cheap, synchronous eligibility (provider / content / finish code) comes
|
|
88
|
+
// first. `deps.hasToken()` is awaited only once everything else already
|
|
89
|
+
// says yes, so a normal call — success or a non-quota failure — never pays
|
|
90
|
+
// a credential resolve, and a credentials-provider rejection can only ever
|
|
91
|
+
// affect a call this plugin was already about to intervene on.
|
|
92
|
+
if (!shouldFallback({ finish, emittedContent, hasToken: true, provider: options.provider })) {
|
|
93
|
+
yield* pendingUsage;
|
|
94
|
+
yield finish;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let hasToken;
|
|
98
|
+
try {
|
|
99
|
+
hasToken = await deps.hasToken();
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
hasToken = false;
|
|
103
|
+
}
|
|
104
|
+
if (!hasToken) {
|
|
105
|
+
yield* pendingUsage;
|
|
106
|
+
yield finish;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Falling back: the primary attempt's pre-retry usage (if any) is dropped
|
|
110
|
+
// on purpose — see the usage invariant note above.
|
|
111
|
+
let announced = false;
|
|
112
|
+
let poolFinished = false;
|
|
113
|
+
try {
|
|
114
|
+
for await (const c of deps.poolStream({ ...options, provider: POOL_PROVIDER })) {
|
|
115
|
+
if (c.type === 'finish' && c.reason.kind === 'error') {
|
|
116
|
+
yield finish;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!announced) {
|
|
120
|
+
announced = true;
|
|
121
|
+
deps.notify('已切换至奖池余额继续本次请求。');
|
|
122
|
+
}
|
|
123
|
+
if (c.type === 'finish')
|
|
124
|
+
poolFinished = true;
|
|
125
|
+
yield c;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
deps.logError?.(error);
|
|
130
|
+
}
|
|
131
|
+
if (!poolFinished)
|
|
132
|
+
yield finish;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Register the pool-route adapter and hang the `llm/stream` fallback
|
|
136
|
+
* waterfall off it.
|
|
137
|
+
*
|
|
138
|
+
* KNOWN LIMITATION — where the switch notice actually goes. `notify` defaults
|
|
139
|
+
* to `ctx.logger.info`, which means the player normally never SEES it: in dsh
|
|
140
|
+
* 0.1.0-rc.6 a waterfall listener has no FIRE-AND-FORGET notice channel it may
|
|
141
|
+
* use. Everything reachable from here was checked (see the plugin spec's §7
|
|
142
|
+
* appendix for the full record): no host service in this plugin's dependency
|
|
143
|
+
* closure is a notification sink (`agents`, `sessions`, `credentials`,
|
|
144
|
+
* `commands`, `llm`, `webServer`, `conversation`, `chatFileMentions`). The one
|
|
145
|
+
* human-visible in-conversation seam that does exist elsewhere in the host,
|
|
146
|
+
* `ctx.userQuestions.ask()`, BLOCKS on a human answer and needs a GUI
|
|
147
|
+
* provider — wrong semantics for a passive notice, and it would stall the very
|
|
148
|
+
* stream this fallback is rescuing. `commands` only runs when the player types
|
|
149
|
+
* a slash command;
|
|
150
|
+
* `agent.inject()` writes a model-visible `user/message`, which is exactly
|
|
151
|
+
* what this project forbids; and `SurfaceEventType` is a CLOSED three-member
|
|
152
|
+
* set, so the UI's generic unclaimed-event fallback covers only
|
|
153
|
+
* model-visible events — a log-only custom event has no renderer at all.
|
|
154
|
+
* Surfacing it would take a plugin-owned `ConversationNodeDefinition` in the
|
|
155
|
+
* client bundle, i.e. a new feature that still would not reach TUI/headless
|
|
156
|
+
* players. The README says plainly that the notice lands in the log, rather
|
|
157
|
+
* than promising an on-screen one. Revisit when dsh grows a plugin-usable
|
|
158
|
+
* in-session notice slot.
|
|
159
|
+
*
|
|
160
|
+
* What is NOT negotiable either way: the notice must never be injected into
|
|
161
|
+
* model messages, so a fallback can never pollute conversation history.
|
|
162
|
+
*
|
|
163
|
+
* `ctx.llm.registerAdapter()` already ties its disposal to the calling
|
|
164
|
+
* fiber (cordis re-binds a service's `this.ctx` to whichever context reads
|
|
165
|
+
* it, so the registration's own internal `ctx.effect` lands on OUR plugin's
|
|
166
|
+
* fiber, not `llm`'s construction context — confirmed by disposing a probe
|
|
167
|
+
* fiber and observing the route drop from `listProviders()` with no extra
|
|
168
|
+
* wrapping). No separate `ctx.effect` around `registration` is needed.
|
|
169
|
+
*/
|
|
170
|
+
export function registerRouter(ctx, deps) {
|
|
171
|
+
const notify = deps.notify ?? ((line) => ctx.logger.info(line));
|
|
172
|
+
ctx.llm.registerAdapter([POOL_PROVIDER], createPoolAdapter(ctx, deps.serverUrl));
|
|
173
|
+
ctx.on('llm/stream', (options, next) => runWithFallback(options, next, {
|
|
174
|
+
hasToken: async () => (await deps.identity.current()) !== undefined,
|
|
175
|
+
poolStream: (o) => ctx.llm.stream(o),
|
|
176
|
+
notify,
|
|
177
|
+
logError: (error) => ctx.logger.warn('dsh-blackjack: pool fallback failed after a quota exhaustion; the original failure was surfaced instead', error),
|
|
178
|
+
}));
|
|
179
|
+
}
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import { type Api } from './api.js';
|
|
3
|
+
import type { Identity } from './identity.js';
|
|
4
|
+
/**
|
|
5
|
+
* Host half of the graphical table: a JSON read/act surface under
|
|
6
|
+
* `/blackjack/api/*` on the web GUI's own `ctx.webServer`, consumed by the
|
|
7
|
+
* browser table (a separate module, task 8). Registered as a NESTED plugin
|
|
8
|
+
* from `src/index.ts` with its own `inject: ['webServer']`, so a headless
|
|
9
|
+
* host without a web server leaves this fiber pending while the text
|
|
10
|
+
* commands keep working — the table is an optional surface, not a
|
|
11
|
+
* requirement.
|
|
12
|
+
*
|
|
13
|
+
* Security: the player's bearer token lives only in the dsh process
|
|
14
|
+
* (`Identity`) — it MUST NEVER appear in a response body. Before consent (no
|
|
15
|
+
* token yet) `GET .../state` reports `{ consented: false }` and every write
|
|
16
|
+
* route refuses with 403; consent can only be granted from the command plane
|
|
17
|
+
* (`/blackjack agree`), never from the browser.
|
|
18
|
+
*
|
|
19
|
+
* The bind address is NOT the whole trust boundary. A dsh host may bind
|
|
20
|
+
* `0.0.0.0`, and even on loopback any page the player has open can reach
|
|
21
|
+
* `http://localhost:<port>` — a cross-site `<form enctype="text/plain">` needs
|
|
22
|
+
* no CORS preflight, so without a check a hostile page could burn free hands
|
|
23
|
+
* through `/api/deal` or force actions through `/api/action`. Two same-origin
|
|
24
|
+
* gates close that (see `isSameOrigin` / `hasJsonBody`):
|
|
25
|
+
* - a request carrying an `Origin` whose host differs from this request's
|
|
26
|
+
* `Host`, or a `Sec-Fetch-Site` that is anything other than `same-origin`,
|
|
27
|
+
* is refused 403 outright (the browser sets both and a page cannot forge
|
|
28
|
+
* either);
|
|
29
|
+
* - the write routes require `content-type: application/json`, which a
|
|
30
|
+
* simple (preflight-free) cross-site form can never send.
|
|
31
|
+
* The browser table (`src/client/index.tsx`) calls these with same-origin
|
|
32
|
+
* relative URLs and an explicit JSON content type, so it satisfies both.
|
|
33
|
+
*
|
|
34
|
+
* Exchange is deliberately NOT here: it is a command-plane concern
|
|
35
|
+
* (`/blackjack exchange`), because the pairing `code` is itself a bearer
|
|
36
|
+
* capability — whoever holds it can claim the exchange — and must never reach
|
|
37
|
+
* the browser through a route this file owns.
|
|
38
|
+
*
|
|
39
|
+
* @module dsh-blackjack/routes
|
|
40
|
+
*/
|
|
41
|
+
/** Route prefix this plugin owns on the host's web server. */
|
|
42
|
+
export declare const PREFIX = "/blackjack";
|
|
43
|
+
/**
|
|
44
|
+
* Register the table's API routes into `ctx`'s effect scope (the route
|
|
45
|
+
* disposer rides the fiber, so plugin dispose unregisters the prefix).
|
|
46
|
+
* @param ctx - a context whose `webServer` service is live (the nested
|
|
47
|
+
* plugin's `inject` guarantees it).
|
|
48
|
+
* @param deps - the same `Api`/`Identity` the command plane uses, so the
|
|
49
|
+
* browser table and the zero-token commands stay consistent.
|
|
50
|
+
*/
|
|
51
|
+
export declare function registerRoutes(ctx: Context, deps: {
|
|
52
|
+
api: Api;
|
|
53
|
+
identity: Identity;
|
|
54
|
+
}): void;
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { ApiError } from './api.js';
|
|
2
|
+
/**
|
|
3
|
+
* Host half of the graphical table: a JSON read/act surface under
|
|
4
|
+
* `/blackjack/api/*` on the web GUI's own `ctx.webServer`, consumed by the
|
|
5
|
+
* browser table (a separate module, task 8). Registered as a NESTED plugin
|
|
6
|
+
* from `src/index.ts` with its own `inject: ['webServer']`, so a headless
|
|
7
|
+
* host without a web server leaves this fiber pending while the text
|
|
8
|
+
* commands keep working — the table is an optional surface, not a
|
|
9
|
+
* requirement.
|
|
10
|
+
*
|
|
11
|
+
* Security: the player's bearer token lives only in the dsh process
|
|
12
|
+
* (`Identity`) — it MUST NEVER appear in a response body. Before consent (no
|
|
13
|
+
* token yet) `GET .../state` reports `{ consented: false }` and every write
|
|
14
|
+
* route refuses with 403; consent can only be granted from the command plane
|
|
15
|
+
* (`/blackjack agree`), never from the browser.
|
|
16
|
+
*
|
|
17
|
+
* The bind address is NOT the whole trust boundary. A dsh host may bind
|
|
18
|
+
* `0.0.0.0`, and even on loopback any page the player has open can reach
|
|
19
|
+
* `http://localhost:<port>` — a cross-site `<form enctype="text/plain">` needs
|
|
20
|
+
* no CORS preflight, so without a check a hostile page could burn free hands
|
|
21
|
+
* through `/api/deal` or force actions through `/api/action`. Two same-origin
|
|
22
|
+
* gates close that (see `isSameOrigin` / `hasJsonBody`):
|
|
23
|
+
* - a request carrying an `Origin` whose host differs from this request's
|
|
24
|
+
* `Host`, or a `Sec-Fetch-Site` that is anything other than `same-origin`,
|
|
25
|
+
* is refused 403 outright (the browser sets both and a page cannot forge
|
|
26
|
+
* either);
|
|
27
|
+
* - the write routes require `content-type: application/json`, which a
|
|
28
|
+
* simple (preflight-free) cross-site form can never send.
|
|
29
|
+
* The browser table (`src/client/index.tsx`) calls these with same-origin
|
|
30
|
+
* relative URLs and an explicit JSON content type, so it satisfies both.
|
|
31
|
+
*
|
|
32
|
+
* Exchange is deliberately NOT here: it is a command-plane concern
|
|
33
|
+
* (`/blackjack exchange`), because the pairing `code` is itself a bearer
|
|
34
|
+
* capability — whoever holds it can claim the exchange — and must never reach
|
|
35
|
+
* the browser through a route this file owns.
|
|
36
|
+
*
|
|
37
|
+
* @module dsh-blackjack/routes
|
|
38
|
+
*/
|
|
39
|
+
/** Route prefix this plugin owns on the host's web server. */
|
|
40
|
+
export const PREFIX = '/blackjack';
|
|
41
|
+
function sendJson(res, status, body) {
|
|
42
|
+
const text = JSON.stringify(body);
|
|
43
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
44
|
+
res.end(text);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Whether this request provably did not come from another origin.
|
|
48
|
+
*
|
|
49
|
+
* Both headers are browser-controlled and unforgeable from page script, so a
|
|
50
|
+
* present-and-wrong value is a positive signal of a cross-site attempt:
|
|
51
|
+
* - `Sec-Fetch-Site` must be exactly `same-origin` when present. `cross-site`
|
|
52
|
+
* and `same-site` are refused for the obvious reason; `none` (a
|
|
53
|
+
* user-typed URL or bookmark) is refused too, because nothing legitimate
|
|
54
|
+
* navigates directly to a JSON endpoint here and refusing costs nothing.
|
|
55
|
+
* - `Origin`, when present, must name the same host this request was
|
|
56
|
+
* addressed to (`Host`). A non-browser client (curl, a test harness) sends
|
|
57
|
+
* neither header and is unaffected — the goal is stopping a hostile PAGE,
|
|
58
|
+
* which cannot suppress them.
|
|
59
|
+
*/
|
|
60
|
+
function isSameOrigin(req) {
|
|
61
|
+
const site = req.headers['sec-fetch-site'];
|
|
62
|
+
if (typeof site === 'string' && site !== 'same-origin')
|
|
63
|
+
return false;
|
|
64
|
+
const origin = req.headers.origin;
|
|
65
|
+
if (typeof origin === 'string' && origin.length > 0) {
|
|
66
|
+
const host = req.headers.host;
|
|
67
|
+
if (!host)
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
if (new URL(origin).host !== host)
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false; // an unparseable Origin (including the opaque `null`) is not ours
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Whether a write request declared a JSON body. Required on every write route:
|
|
81
|
+
* the cross-site form POST this file defends against can only send
|
|
82
|
+
* `text/plain`, `application/x-www-form-urlencoded`, or `multipart/form-data`
|
|
83
|
+
* without triggering a CORS preflight, so demanding JSON is a second,
|
|
84
|
+
* independent gate that does not depend on any `Sec-Fetch-*` support.
|
|
85
|
+
*/
|
|
86
|
+
function hasJsonBody(req) {
|
|
87
|
+
const type = req.headers['content-type'];
|
|
88
|
+
return typeof type === 'string' && type.split(';')[0].trim().toLowerCase() === 'application/json';
|
|
89
|
+
}
|
|
90
|
+
/** Read the request body as JSON. Any non-object result — empty body, parse failure, array, null — becomes `{}`. */
|
|
91
|
+
async function readJsonBody(req) {
|
|
92
|
+
const chunks = [];
|
|
93
|
+
for await (const chunk of req) {
|
|
94
|
+
chunks.push(chunk);
|
|
95
|
+
}
|
|
96
|
+
if (chunks.length === 0)
|
|
97
|
+
return {};
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
100
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
101
|
+
? parsed
|
|
102
|
+
: {};
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return {};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Map a caught failure to a player-safe status/body pair. Never leaks stack traces, URLs, or raw messages. */
|
|
109
|
+
function mapError(e) {
|
|
110
|
+
if (e instanceof ApiError) {
|
|
111
|
+
const status = e.status === 0 ? 502 : e.status;
|
|
112
|
+
return { status, body: { error: e.code ?? 'upstream error' } };
|
|
113
|
+
}
|
|
114
|
+
return { status: 500, body: { error: 'internal error' } };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Register the table's API routes into `ctx`'s effect scope (the route
|
|
118
|
+
* disposer rides the fiber, so plugin dispose unregisters the prefix).
|
|
119
|
+
* @param ctx - a context whose `webServer` service is live (the nested
|
|
120
|
+
* plugin's `inject` guarantees it).
|
|
121
|
+
* @param deps - the same `Api`/`Identity` the command plane uses, so the
|
|
122
|
+
* browser table and the zero-token commands stay consistent.
|
|
123
|
+
*/
|
|
124
|
+
export function registerRoutes(ctx, deps) {
|
|
125
|
+
const { api, identity } = deps;
|
|
126
|
+
async function handle(req, res) {
|
|
127
|
+
const url = new URL(req.url ?? '/', 'http://blackjack.local');
|
|
128
|
+
const route = url.pathname;
|
|
129
|
+
const method = req.method ?? 'GET';
|
|
130
|
+
if (!isSameOrigin(req))
|
|
131
|
+
return sendJson(res, 403, { error: 'cross-origin request refused' });
|
|
132
|
+
try {
|
|
133
|
+
if (route === `${PREFIX}/api/state` && method === 'GET') {
|
|
134
|
+
const token = await identity.current();
|
|
135
|
+
if (!token)
|
|
136
|
+
return sendJson(res, 200, { consented: false });
|
|
137
|
+
const [me, round] = await Promise.all([api.me(token), api.activeRound(token)]);
|
|
138
|
+
return sendJson(res, 200, { consented: true, me, round });
|
|
139
|
+
}
|
|
140
|
+
if (route === `${PREFIX}/api/deal` && method === 'POST') {
|
|
141
|
+
if (!hasJsonBody(req))
|
|
142
|
+
return sendJson(res, 415, { error: 'expected content-type: application/json' });
|
|
143
|
+
const token = await identity.current();
|
|
144
|
+
if (!token)
|
|
145
|
+
return sendJson(res, 403, { error: 'not consented' });
|
|
146
|
+
const body = await readJsonBody(req);
|
|
147
|
+
const betChips = body.betChips;
|
|
148
|
+
let startBody;
|
|
149
|
+
if (betChips === undefined) {
|
|
150
|
+
startBody = { mode: 'free' };
|
|
151
|
+
}
|
|
152
|
+
else if (typeof betChips === 'number' && Number.isInteger(betChips) && betChips > 0) {
|
|
153
|
+
startBody = { mode: 'raise', betChips };
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
return sendJson(res, 400, { error: 'invalid betChips' });
|
|
157
|
+
}
|
|
158
|
+
const round = await api.startRound(token, startBody);
|
|
159
|
+
return sendJson(res, 200, round);
|
|
160
|
+
}
|
|
161
|
+
if (route === `${PREFIX}/api/action` && method === 'POST') {
|
|
162
|
+
if (!hasJsonBody(req))
|
|
163
|
+
return sendJson(res, 415, { error: 'expected content-type: application/json' });
|
|
164
|
+
const token = await identity.current();
|
|
165
|
+
if (!token)
|
|
166
|
+
return sendJson(res, 403, { error: 'not consented' });
|
|
167
|
+
const body = await readJsonBody(req);
|
|
168
|
+
if (typeof body.action !== 'string' || body.action.length === 0) {
|
|
169
|
+
return sendJson(res, 400, { error: 'invalid action' });
|
|
170
|
+
}
|
|
171
|
+
const round = await api.activeRound(token);
|
|
172
|
+
if (!round)
|
|
173
|
+
return sendJson(res, 404, { error: 'no active round' });
|
|
174
|
+
const result = await api.act(token, round.id, body.action);
|
|
175
|
+
return sendJson(res, 200, result);
|
|
176
|
+
}
|
|
177
|
+
return sendJson(res, 404, { error: 'unknown route' });
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
const { status, body } = mapError(e);
|
|
181
|
+
return sendJson(res, status, body);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
ctx.effect(() => ctx.webServer.register({
|
|
185
|
+
kind: 'prefix',
|
|
186
|
+
path: PREFIX,
|
|
187
|
+
handler: (req, res) => handle(req, res).catch((err) => {
|
|
188
|
+
ctx.logger.warn('dsh-blackjack: web route failed: %o', err);
|
|
189
|
+
if (!res.headersSent)
|
|
190
|
+
sendJson(res, 500, { error: 'internal error' });
|
|
191
|
+
else
|
|
192
|
+
res.end();
|
|
193
|
+
}),
|
|
194
|
+
}), 'dsh-blackjack: /blackjack web routes');
|
|
195
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-blackjack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "社区福利小游戏插件:在 dsh 里玩 21 点赢取可消费的模型额度",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"blackjack",
|
|
9
|
+
"community"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./client": "./dist/client.js",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"blackjack.cordis.yml",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
30
|
+
},
|
|
31
|
+
"dsh": {
|
|
32
|
+
"bundle": {
|
|
33
|
+
"patch": "./blackjack.cordis.yml"
|
|
34
|
+
},
|
|
35
|
+
"client": {
|
|
36
|
+
"platform": "web",
|
|
37
|
+
"inject": [
|
|
38
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json && node build.client.mjs",
|
|
45
|
+
"prepack": "pnpm run build",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
48
|
+
"prepare": "pnpm run build"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
52
|
+
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
|
53
|
+
"@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.6",
|
|
54
|
+
"@deepseek-ai/dsh-commands": "^0.1.0-rc.6",
|
|
55
|
+
"@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
|
|
56
|
+
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
|
57
|
+
"@deepseek-ai/dsh-llm-deepseek": "^0.1.0-rc.6",
|
|
58
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.6"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@dsh-blackjack/server": "workspace:*",
|
|
65
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
66
|
+
"@deepseek-ai/dsh-agent": "0.1.0-rc.6",
|
|
67
|
+
"@deepseek-ai/dsh-anonymous-user-id": "0.1.0-rc.6",
|
|
68
|
+
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6",
|
|
69
|
+
"@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.6",
|
|
70
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6",
|
|
71
|
+
"@deepseek-ai/dsh-commands": "0.1.0-rc.6",
|
|
72
|
+
"@deepseek-ai/dsh-credentials": "0.1.0-rc.6",
|
|
73
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.8",
|
|
74
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
75
|
+
"@deepseek-ai/dsh-llm-deepseek": "0.1.0-rc.6",
|
|
76
|
+
"@deepseek-ai/dsh-session": "0.1.0-rc.6",
|
|
77
|
+
"@types/node": "^22.0.0",
|
|
78
|
+
"@types/react": "~18.3.1",
|
|
79
|
+
"@types/react-dom": "~18.3.0",
|
|
80
|
+
"esbuild": "^0.24.0",
|
|
81
|
+
"react": "^18.2.0",
|
|
82
|
+
"react-dom": "^18.2.0",
|
|
83
|
+
"typescript": "^5.6.0",
|
|
84
|
+
"vitest": "^2.1.0"
|
|
85
|
+
},
|
|
86
|
+
"repository": {
|
|
87
|
+
"type": "git",
|
|
88
|
+
"url": "git+https://github.com/yul761/dsh-blackjack.git",
|
|
89
|
+
"directory": "packages/plugin"
|
|
90
|
+
},
|
|
91
|
+
"homepage": "https://github.com/yul761/dsh-blackjack/tree/master/packages/plugin#readme",
|
|
92
|
+
"bugs": {
|
|
93
|
+
"url": "https://github.com/yul761/dsh-blackjack/issues"
|
|
94
|
+
}
|
|
95
|
+
}
|