@linxin666/dsh-client-ui-task-board 0.3.22 → 0.3.23
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 +2 -0
- package/README.zh.md +2 -0
- package/lib/client.js +458 -30
- package/lib/client.js.map +1 -1
- package/lib/index.js +2553 -156
- package/lib/types/client/board/EditTaskModal.d.ts +6 -0
- package/lib/types/client/board/NewTaskModal.d.ts +6 -1
- package/lib/types/client/board/TaskBoard.d.ts +2 -0
- package/lib/types/client/board/TaskForm.d.ts +2 -2
- package/lib/types/client/host-api.d.ts +23 -1
- package/lib/types/client/locales.d.ts +25 -0
- package/lib/types/core/controller.d.ts +35 -1
- package/lib/types/host-ai.d.ts +51 -0
- package/lib/types/host-routes.d.ts +11 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/protocol.d.ts +19 -0
- package/package.json +1 -1
- package/src/client/board/EditTaskModal.tsx +36 -0
- package/src/client/board/NewTaskModal.tsx +91 -3
- package/src/client/board/TaskBoard.tsx +89 -0
- package/src/client/board/TaskDetail.tsx +38 -2
- package/src/client/board/TaskForm.tsx +4 -11
- package/src/client/board.module.css +61 -0
- package/src/client/host-api.ts +124 -5
- package/src/client/index.ts +7 -0
- package/src/client/locales.ts +50 -0
- package/src/core/controller.ts +57 -1
- package/src/host-ai.ts +152 -0
- package/src/host-ledger.ts +25 -3
- package/src/host-routes.ts +68 -3
- package/src/index.ts +26 -1
- package/src/protocol.ts +34 -0
package/src/host-ai.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot model parse behind the board's "parse pasted text" action
|
|
3
|
+
* (issue #1540).
|
|
4
|
+
*
|
|
5
|
+
* The browser half never talks to a model: it posts the pasted text and the
|
|
6
|
+
* route the user picked to the Host, which calls the injected `llm` service
|
|
7
|
+
* once and answers with the three draft fields the new-task form accepts. The
|
|
8
|
+
* reply is untrusted text, so extraction is defensive: code fences are
|
|
9
|
+
* stripped, the first JSON object wins, and an unusable reply falls back to
|
|
10
|
+
* the user's own words instead of an empty form.
|
|
11
|
+
*/
|
|
12
|
+
import { createUserMessage, type GenerateOptions, type LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
13
|
+
import type { TaskBoardParseDraft, TaskBoardParseRequest } from './protocol.ts'
|
|
14
|
+
|
|
15
|
+
/** How long one parse may take before the Host gives up on the model. */
|
|
16
|
+
export const TASK_PARSE_TIMEOUT_MS = 45_000
|
|
17
|
+
/** Largest pasted text one parse accepts (bytes). */
|
|
18
|
+
export const TASK_PARSE_MAX_INPUT = 16 * 1024
|
|
19
|
+
/** Longest title the form is willing to receive from a model. */
|
|
20
|
+
const TASK_PARSE_TITLE_LIMIT = 120
|
|
21
|
+
|
|
22
|
+
export type TaskParseFailureCode = 'no-model' | 'model-error' | 'parse-failed' | 'timeout'
|
|
23
|
+
|
|
24
|
+
/** Typed parse failure; the route maps the code onto a status and a body. */
|
|
25
|
+
export class TaskParseError extends Error {
|
|
26
|
+
constructor(readonly code: TaskParseFailureCode, message: string) {
|
|
27
|
+
super(message)
|
|
28
|
+
this.name = 'TaskParseError'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SYSTEM_PROMPT = [
|
|
33
|
+
'You turn one pasted note into a task for a kanban board.',
|
|
34
|
+
'Reply with a single JSON object and nothing else, with exactly these keys:',
|
|
35
|
+
'{"title": string, "description": string, "prompt": string}',
|
|
36
|
+
'- title: one line, at most 80 characters, in the language of the note.',
|
|
37
|
+
'- description: what the task is and the context the note carries, as plain text.',
|
|
38
|
+
'- prompt: the instruction an agent should execute, keeping every fact that matters.',
|
|
39
|
+
].join('\n')
|
|
40
|
+
|
|
41
|
+
/** Split a qualified `provider/model` route the same way the runner does. */
|
|
42
|
+
export function splitModelRoute(qualified: string | undefined): { provider: string; model: string } | undefined {
|
|
43
|
+
const raw = qualified?.trim() ?? ''
|
|
44
|
+
if (raw === '') return undefined
|
|
45
|
+
const slash = raw.indexOf('/')
|
|
46
|
+
if (slash <= 0 || slash === raw.length - 1) return undefined
|
|
47
|
+
const provider = raw.slice(0, slash).trim()
|
|
48
|
+
const model = raw.slice(slash + 1).trim()
|
|
49
|
+
if (provider === '' || model === '') return undefined
|
|
50
|
+
return { provider, model }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Trim one model-provided field to a single-line, bounded string. */
|
|
54
|
+
function field(value: unknown, limit?: number): string {
|
|
55
|
+
if (typeof value !== 'string') return ''
|
|
56
|
+
const text = value.trim()
|
|
57
|
+
return limit === undefined || text.length <= limit ? text : text.slice(0, limit).trim()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** First non-empty line of the pasted text, used when the model omits a title. */
|
|
61
|
+
function firstLine(source: string): string {
|
|
62
|
+
const line = source.split(/\r?\n/).map(entry => entry.trim()).find(entry => entry !== '') ?? ''
|
|
63
|
+
return line.length <= TASK_PARSE_TITLE_LIMIT ? line : line.slice(0, TASK_PARSE_TITLE_LIMIT).trim()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read the draft out of a model reply. Fences and surrounding prose are
|
|
68
|
+
* tolerated; anything unusable returns undefined so the caller can decide
|
|
69
|
+
* between a typed failure and a fallback.
|
|
70
|
+
* @param reply - raw model text.
|
|
71
|
+
*/
|
|
72
|
+
export function extractTaskParseReply(reply: string): Partial<TaskBoardParseDraft> | undefined {
|
|
73
|
+
const withoutFences = reply.replace(/```[a-zA-Z]*\s*/g, '')
|
|
74
|
+
const start = withoutFences.indexOf('{')
|
|
75
|
+
const end = withoutFences.lastIndexOf('}')
|
|
76
|
+
if (start === -1 || end <= start) return undefined
|
|
77
|
+
let payload: unknown
|
|
78
|
+
try {
|
|
79
|
+
payload = JSON.parse(withoutFences.slice(start, end + 1))
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
if (typeof payload !== 'object' || payload === null) return undefined
|
|
84
|
+
const record = payload as { title?: unknown; description?: unknown; prompt?: string }
|
|
85
|
+
const draft: Partial<TaskBoardParseDraft> = {
|
|
86
|
+
title: field(record.title, TASK_PARSE_TITLE_LIMIT),
|
|
87
|
+
description: field(record.description),
|
|
88
|
+
prompt: field(record.prompt),
|
|
89
|
+
}
|
|
90
|
+
return draft.title === '' && draft.description === '' && draft.prompt === '' ? undefined : draft
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fill the three form fields, falling back to the pasted text so a weak model
|
|
95
|
+
* reply never loses what the user actually wrote.
|
|
96
|
+
* @param reply - raw model text.
|
|
97
|
+
* @param source - the text the user pasted.
|
|
98
|
+
*/
|
|
99
|
+
export function draftFromReply(reply: string, source: string): TaskBoardParseDraft {
|
|
100
|
+
const parsed = extractTaskParseReply(reply)
|
|
101
|
+
return {
|
|
102
|
+
title: parsed?.title !== undefined && parsed.title !== '' ? parsed.title : firstLine(source),
|
|
103
|
+
description: parsed?.description ?? '',
|
|
104
|
+
prompt: parsed?.prompt !== undefined && parsed.prompt !== '' ? parsed.prompt : source.trim(),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse one pasted text through the injected `llm` service.
|
|
110
|
+
* @param llm - the Host's llm service.
|
|
111
|
+
* @param request - pasted text plus the qualified model route.
|
|
112
|
+
* @param signal - caller cancellation (client disconnect); combined with the timeout.
|
|
113
|
+
* @returns the draft fields.
|
|
114
|
+
* @throws TaskParseError with a code the route maps onto a status.
|
|
115
|
+
*/
|
|
116
|
+
export async function parseTaskDraft(
|
|
117
|
+
llm: LlmRuntime,
|
|
118
|
+
request: TaskBoardParseRequest,
|
|
119
|
+
signal?: AbortSignal,
|
|
120
|
+
): Promise<TaskBoardParseDraft> {
|
|
121
|
+
const text = request.text.trim()
|
|
122
|
+
if (text === '') throw new TaskParseError('parse-failed', 'there is nothing to parse')
|
|
123
|
+
const route = splitModelRoute(request.model)
|
|
124
|
+
if (route === undefined) throw new TaskParseError('no-model', 'no model route was selected for parsing')
|
|
125
|
+
const timeout = new AbortController()
|
|
126
|
+
const timer = setTimeout(() => { timeout.abort() }, TASK_PARSE_TIMEOUT_MS)
|
|
127
|
+
const abortFromCaller = (): void => { timeout.abort() }
|
|
128
|
+
signal?.addEventListener('abort', abortFromCaller, { once: true })
|
|
129
|
+
try {
|
|
130
|
+
const options: GenerateOptions = {
|
|
131
|
+
provider: route.provider,
|
|
132
|
+
model: route.model,
|
|
133
|
+
system: SYSTEM_PROMPT,
|
|
134
|
+
messages: [createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })],
|
|
135
|
+
signal: timeout.signal,
|
|
136
|
+
}
|
|
137
|
+
let reply = ''
|
|
138
|
+
for await (const chunk of llm.stream(options)) {
|
|
139
|
+
if (chunk.type === 'text-delta') reply += chunk.text
|
|
140
|
+
}
|
|
141
|
+
return draftFromReply(reply, text)
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof TaskParseError) throw error
|
|
144
|
+
if (timeout.signal.aborted) {
|
|
145
|
+
throw new TaskParseError('timeout', `the model did not answer within ${TASK_PARSE_TIMEOUT_MS / 1_000}s`)
|
|
146
|
+
}
|
|
147
|
+
throw new TaskParseError('model-error', error instanceof Error ? error.message : String(error))
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timer)
|
|
150
|
+
signal?.removeEventListener('abort', abortFromCaller)
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/host-ledger.ts
CHANGED
|
@@ -216,6 +216,16 @@ function ownProcessStartTimeMs(): number | undefined {
|
|
|
216
216
|
*/
|
|
217
217
|
const LEGACY_START_TOLERANCE_MS = 2000
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* How long an unreadable lock must sit untouched before it may be reclaimed.
|
|
221
|
+
* The owner writes and fsyncs its record immediately after creating the file
|
|
222
|
+
* with O_EXCL, so a lock that cannot be parsed may still be mid-write by a
|
|
223
|
+
* live owner; only one that has been unreadable for longer than any write can
|
|
224
|
+
* take is treated as an unclean-shutdown leftover (issue #1528: a 0-byte lock
|
|
225
|
+
* kept the Host half from mounting until it was deleted by hand).
|
|
226
|
+
*/
|
|
227
|
+
const UNREADABLE_LOCK_GRACE_MS = 60_000
|
|
228
|
+
|
|
219
229
|
/** Whether the recorded start time proves the recorded PID is another process. */
|
|
220
230
|
function startTimeMismatch(recorded: number, actual: number, exact: boolean): boolean {
|
|
221
231
|
return exact ? recorded !== actual : Math.abs(recorded - actual) > LEGACY_START_TOLERANCE_MS
|
|
@@ -842,9 +852,21 @@ export class HostTaskLedger {
|
|
|
842
852
|
if (typeof owner.startedAt === 'number') ownerStartedAt = owner.startedAt
|
|
843
853
|
ownerExact = owner.probe === 'exact'
|
|
844
854
|
} catch {
|
|
845
|
-
// A power-loss mid-write can leave
|
|
846
|
-
//
|
|
847
|
-
|
|
855
|
+
// A power-loss mid-write can leave an empty or truncated lock. Such a
|
|
856
|
+
// lock still fails closed while it is fresh (a live owner may be
|
|
857
|
+
// mid-write); once it is older than the grace window nothing can be
|
|
858
|
+
// writing it, so the leftover is reclaimed instead of blocking every
|
|
859
|
+
// later start until someone deletes it by hand (issue #1528).
|
|
860
|
+
const age = (() => {
|
|
861
|
+
try { return this.now() - statSync(this.lockFile).mtimeMs } catch { return Number.POSITIVE_INFINITY }
|
|
862
|
+
})()
|
|
863
|
+
if (age < UNREADABLE_LOCK_GRACE_MS) {
|
|
864
|
+
throw new Error(`task-board ledger lock is unreadable: ${this.lockFile}; if this is a leftover from an unclean shutdown and no other DSH host is running, remove it manually and retry`)
|
|
865
|
+
}
|
|
866
|
+
try { unlinkSync(this.lockFile) } catch (unlinkError) {
|
|
867
|
+
if ((unlinkError as NodeJS.ErrnoException).code !== 'ENOENT') throw unlinkError
|
|
868
|
+
}
|
|
869
|
+
continue
|
|
848
870
|
}
|
|
849
871
|
if (pid !== undefined && processIsAlive(pid)) {
|
|
850
872
|
const actualStartedAt = pid === process.pid ? ownProcessStartTimeMs() : processStartTimeMs(pid)
|
package/src/host-routes.ts
CHANGED
|
@@ -4,7 +4,14 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
|
4
4
|
import type { TaskBoardHostService } from './host-service.ts'
|
|
5
5
|
import { writeJson } from './http.ts'
|
|
6
6
|
import { isLoopbackAddress, isLoopbackRequest } from './loopback.ts'
|
|
7
|
-
import {
|
|
7
|
+
import { TaskParseError, TASK_PARSE_MAX_INPUT } from './host-ai.ts'
|
|
8
|
+
import {
|
|
9
|
+
parseActionEnvelope,
|
|
10
|
+
parseTaskParseRequest,
|
|
11
|
+
TASK_BOARD_API_PREFIX,
|
|
12
|
+
type TaskBoardParseDraft,
|
|
13
|
+
type TaskBoardParseRequest,
|
|
14
|
+
} from './protocol.ts'
|
|
8
15
|
|
|
9
16
|
const ACTION_LIMIT = 64 * 1024
|
|
10
17
|
const IMPORT_LIMIT = 2 * 1024 * 1024
|
|
@@ -19,6 +26,16 @@ export interface TaskBoardRouteAccess {
|
|
|
19
26
|
proxyToken?: string
|
|
20
27
|
}
|
|
21
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Host faces the routes need beyond the ledger service. The parse face is
|
|
31
|
+
* resolved lazily by the caller, so a deployment without an llm service still
|
|
32
|
+
* registers the route and answers with a typed "no model" failure instead of
|
|
33
|
+
* leaving the panel with an unmounted path (issue #1540).
|
|
34
|
+
*/
|
|
35
|
+
export interface TaskBoardRouteOptions {
|
|
36
|
+
parseTask?: (request: TaskBoardParseRequest, signal: AbortSignal) => Promise<TaskBoardParseDraft>
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
interface ResolvedRouteAccess {
|
|
23
40
|
trustedProxyHosts: ReadonlySet<string>
|
|
24
41
|
proxyToken?: string
|
|
@@ -119,7 +136,11 @@ async function readBody(req: IncomingMessage): Promise<{ raw: string; value: unk
|
|
|
119
136
|
return { raw, value: JSON.parse(raw) }
|
|
120
137
|
}
|
|
121
138
|
|
|
122
|
-
export function makeTaskBoardRoutes(
|
|
139
|
+
export function makeTaskBoardRoutes(
|
|
140
|
+
service: TaskBoardHostService,
|
|
141
|
+
access: TaskBoardRouteAccess = {},
|
|
142
|
+
options: TaskBoardRouteOptions = {},
|
|
143
|
+
): WebRoute[] {
|
|
123
144
|
const resolvedAccess = resolveAccess(access)
|
|
124
145
|
const guard = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
125
146
|
if (isTrustedTaskBoardRequest(req, resolvedAccess)) return true
|
|
@@ -188,5 +209,49 @@ export function makeTaskBoardRoutes(service: TaskBoardHostService, access: TaskB
|
|
|
188
209
|
push()
|
|
189
210
|
},
|
|
190
211
|
}
|
|
191
|
-
|
|
212
|
+
const parse: WebRoute = {
|
|
213
|
+
kind: 'exact',
|
|
214
|
+
path: `${TASK_BOARD_API_PREFIX}/parse`,
|
|
215
|
+
handler: async (req, res): Promise<void> => {
|
|
216
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' })
|
|
217
|
+
if (!guard(req, res)) return
|
|
218
|
+
if (!(req.headers['content-type'] ?? '').toLowerCase().startsWith('application/json')) {
|
|
219
|
+
return writeJson(res, 415, { ok: false, error: 'json-required' }, { 'cache-control': 'no-store' })
|
|
220
|
+
}
|
|
221
|
+
let body: { raw: string; value: unknown }
|
|
222
|
+
try {
|
|
223
|
+
body = await readBody(req)
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
226
|
+
return writeJson(res, message === 'body-too-large' ? 413 : 400, { ok: false, error: message }, { 'cache-control': 'no-store' })
|
|
227
|
+
}
|
|
228
|
+
const request = parseTaskParseRequest(body.value)
|
|
229
|
+
if (request === undefined) return writeJson(res, 400, { ok: false, error: 'invalid-parse-request' }, { 'cache-control': 'no-store' })
|
|
230
|
+
if (Buffer.byteLength(request.text, 'utf8') > TASK_PARSE_MAX_INPUT) {
|
|
231
|
+
return writeJson(res, 413, { ok: false, error: 'text-too-large' }, { 'cache-control': 'no-store' })
|
|
232
|
+
}
|
|
233
|
+
const parseTask = options.parseTask
|
|
234
|
+
if (parseTask === undefined) {
|
|
235
|
+
return writeJson(res, 503, { ok: false, code: 'no-model', error: 'task-board parsing is unavailable' }, { 'cache-control': 'no-store' })
|
|
236
|
+
}
|
|
237
|
+
// The model call outlives a closed tab: stop it when the response goes
|
|
238
|
+
// away instead of holding the provider request open for the full timeout.
|
|
239
|
+
const controller = new AbortController()
|
|
240
|
+
const onClose = (): void => { controller.abort() }
|
|
241
|
+
res.once('close', onClose)
|
|
242
|
+
try {
|
|
243
|
+
const draft = await parseTask(request, controller.signal)
|
|
244
|
+
writeJson(res, 200, { ok: true, draft }, { 'cache-control': 'no-store' })
|
|
245
|
+
} catch (error) {
|
|
246
|
+
const failure = error instanceof TaskParseError
|
|
247
|
+
? error
|
|
248
|
+
: new TaskParseError('model-error', error instanceof Error ? error.message : String(error))
|
|
249
|
+
const status = failure.code === 'no-model' ? 503 : failure.code === 'timeout' ? 504 : 502
|
|
250
|
+
writeJson(res, status, { ok: false, code: failure.code, error: failure.message }, { 'cache-control': 'no-store' })
|
|
251
|
+
} finally {
|
|
252
|
+
res.off('close', onClose)
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
return [state, action, events, parse]
|
|
192
257
|
}
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,9 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
|
15
15
|
import type {} from '@deepseek-ai/dsh-api-gateway'
|
|
16
16
|
import type {} from '@deepseek-ai/dsh-workspace'
|
|
17
17
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
18
|
+
import type { LlmRuntime } from '@deepseek-ai/dsh-llm'
|
|
18
19
|
import { TaskBoardHostService } from './host-service.ts'
|
|
20
|
+
import { parseTaskDraft, TaskParseError } from './host-ai.ts'
|
|
19
21
|
import { TASK_PERMISSIONS, type TaskPermission } from './core/tasks.ts'
|
|
20
22
|
import { DEFAULT_SESSION_PERMISSION } from './core/handover.ts'
|
|
21
23
|
import { makeTaskBoardRoutes } from './host-routes.ts'
|
|
@@ -88,6 +90,22 @@ export function resolveProxyAccess(config: Config | undefined, env: NodeJS.Proce
|
|
|
88
90
|
/** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
|
|
89
91
|
const DEFAULT_ANNOUNCE = false
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Read the optional `llm` service. The board deliberately does not inject it:
|
|
95
|
+
* a deployment without a model must still mount the board, and the parse route
|
|
96
|
+
* answers a typed failure instead of the plugin failing to load (issue #1540).
|
|
97
|
+
* @param ctx - the plugin context.
|
|
98
|
+
* @returns the llm service, or undefined when this deployment serves none.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveLlmRuntime(ctx: Context): LlmRuntime | undefined {
|
|
101
|
+
try {
|
|
102
|
+
const llm = ctx.get('llm') as LlmRuntime | undefined
|
|
103
|
+
return llm !== undefined && typeof (llm as { stream?: unknown }).stream === 'function' ? llm : undefined
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
91
109
|
/**
|
|
92
110
|
* Register the board's announcement section, gated on the composition entry's
|
|
93
111
|
* `announceToAgent` (and the live settings value once the web settings
|
|
@@ -115,7 +133,14 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
115
133
|
ctx.effect(() => {
|
|
116
134
|
const disposers: Array<() => void> = []
|
|
117
135
|
try {
|
|
118
|
-
|
|
136
|
+
const routes = makeTaskBoardRoutes(host, resolveProxyAccess(config), {
|
|
137
|
+
parseTask: async (request, signal) => {
|
|
138
|
+
const llm = resolveLlmRuntime(ctx)
|
|
139
|
+
if (llm === undefined) throw new TaskParseError('no-model', 'this deployment serves no llm service')
|
|
140
|
+
return await parseTaskDraft(llm, request, signal)
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
for (const route of routes) disposers.push(ctx.webServer.register(route))
|
|
119
144
|
} catch (error) {
|
|
120
145
|
for (const dispose of disposers) dispose()
|
|
121
146
|
host.dispose()
|
package/src/protocol.ts
CHANGED
|
@@ -49,6 +49,40 @@ export interface TaskBoardEventPayload {
|
|
|
49
49
|
power: TaskBoardPowerSnapshot
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Request body of `POST {TASK_BOARD_API_PREFIX}/parse` (issue #1540): the text
|
|
54
|
+
* the user pasted plus the `provider/model` route that should parse it.
|
|
55
|
+
*/
|
|
56
|
+
export interface TaskBoardParseRequest {
|
|
57
|
+
text: string
|
|
58
|
+
/** Qualified route from the model picker; absent when no route was chosen. */
|
|
59
|
+
model?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Draft fields a parse returns; the user reviews them before creating the task. */
|
|
63
|
+
export interface TaskBoardParseDraft {
|
|
64
|
+
title: string
|
|
65
|
+
description: string
|
|
66
|
+
prompt: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Strict parse of a `/parse` request body; undefined rejects the request. */
|
|
70
|
+
export function parseTaskParseRequest(value: unknown): TaskBoardParseRequest | undefined {
|
|
71
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
72
|
+
const record = value as { text?: unknown; model?: unknown }
|
|
73
|
+
if (typeof record.text !== 'string' || record.text.trim() === '') return undefined
|
|
74
|
+
if (record.model !== undefined && typeof record.model !== 'string') return undefined
|
|
75
|
+
const model = typeof record.model === 'string' ? record.model.trim() : ''
|
|
76
|
+
return { text: record.text, ...(model === '' ? {} : { model }) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether a decoded reply carries the three draft strings the form accepts. */
|
|
80
|
+
export function isTaskParseDraft(value: unknown): value is TaskBoardParseDraft {
|
|
81
|
+
if (typeof value !== 'object' || value === null) return false
|
|
82
|
+
const record = value as { title?: unknown; description?: unknown; prompt?: unknown }
|
|
83
|
+
return typeof record.title === 'string' && typeof record.description === 'string' && typeof record.prompt === 'string'
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
export type TaskBoardAction =
|
|
53
87
|
| { kind: 'import'; sourceId: string; tasks: TaskRecord[] }
|
|
54
88
|
| { kind: 'create'; id: string; input: NewTaskInput }
|