@blynkai/cli 0.1.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.
@@ -0,0 +1,201 @@
1
+ import { ErrorCode } from '../core/errors.mjs'
2
+ import { fail } from '../core/response.mjs'
3
+
4
+ export const SEARCH_SCENE_CONFIG = {
5
+ 'data-center': {
6
+ upstreamPath: '/v1/article/search',
7
+ timeKind: 'publish',
8
+ defaultSortField: 'publish_time',
9
+ },
10
+ 'warning-center': {
11
+ upstreamPath: '/v1/index',
12
+ timeKind: 'push',
13
+ defaultSortField: 'push_time',
14
+ },
15
+ 'website-stats': {
16
+ upstreamPath: '/v1/article/website',
17
+ timeKind: 'auto',
18
+ defaultSortField: 'publish_time',
19
+ },
20
+ }
21
+
22
+ function normalizeCenter(value, scene) {
23
+ if (value === 'warning' || scene === 'warning-center') return 'warning'
24
+ return 'data'
25
+ }
26
+
27
+ function resolveSearchConfig(body) {
28
+ const center = normalizeCenter(body.center, body.scene)
29
+ const stats = body.output?.website_stats || body.output?.stats === 'website' ? 'website' : ''
30
+ const scene =
31
+ stats === 'website' ? 'website-stats' : center === 'warning' ? 'warning-center' : 'data-center'
32
+
33
+ return {
34
+ center,
35
+ stats,
36
+ scene,
37
+ config: {
38
+ ...SEARCH_SCENE_CONFIG[scene],
39
+ timeKind: center === 'warning' ? 'push' : 'publish',
40
+ defaultSortField: center === 'warning' ? 'push_time' : 'publish_time',
41
+ },
42
+ }
43
+ }
44
+
45
+ function isPresent(value) {
46
+ if (value === undefined || value === null) return false
47
+ if (Array.isArray(value)) return value.length > 0
48
+ if (typeof value === 'string') return value.trim() !== ''
49
+ return true
50
+ }
51
+
52
+ function cleanObject(value) {
53
+ return Object.fromEntries(
54
+ Object.entries(value).filter(([, item]) => {
55
+ if (item === undefined) return false
56
+ if (Array.isArray(item)) return item.length > 0
57
+ return true
58
+ }),
59
+ )
60
+ }
61
+
62
+ function formatDateTime(date) {
63
+ const pad = (value) => String(value).padStart(2, '0')
64
+ return [
65
+ date.getFullYear(),
66
+ pad(date.getMonth() + 1),
67
+ pad(date.getDate()),
68
+ ].join('-')
69
+ + 'T'
70
+ + [pad(date.getHours()), pad(date.getMinutes()), pad(date.getSeconds())].join(':')
71
+ }
72
+
73
+ function normalizeDateTime(value) {
74
+ if (!isPresent(value)) return ''
75
+ if (typeof value !== 'string') return String(value)
76
+ const trimmed = value.trim()
77
+ if (!trimmed) return ''
78
+
79
+ const noTimezone = trimmed.replace(/Z$/, '').replace(/[+-]\d{2}:\d{2}$/, '')
80
+ const normalized = noTimezone.replace(/\.\d+$/, '')
81
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(normalized)) return normalized
82
+
83
+ const parsed = new Date(trimmed)
84
+ if (Number.isNaN(parsed.getTime())) return normalized
85
+ return formatDateTime(parsed)
86
+ }
87
+
88
+ function defaultTimeRange() {
89
+ const end = new Date()
90
+ const start = new Date(end.getTime() - 24 * 60 * 60 * 1000)
91
+ return {
92
+ start: formatDateTime(start),
93
+ end: formatDateTime(end),
94
+ }
95
+ }
96
+
97
+ function resolveTimeKind(scene, body) {
98
+ const config = SEARCH_SCENE_CONFIG[scene]
99
+ if (!config) return 'publish'
100
+ if (config.timeKind !== 'auto') return config.timeKind
101
+
102
+ if (isPresent(body.time?.push_start) || isPresent(body.time?.push_end)) return 'push'
103
+ return 'publish'
104
+ }
105
+
106
+ export function normalizeSearchPayload(body) {
107
+ const { center, stats, scene, config } = resolveSearchConfig(body)
108
+ if (!config) {
109
+ return {
110
+ error: fail(ErrorCode.INVALID_PARAMS, {
111
+ field: 'center',
112
+ supported: ['data', 'warning'],
113
+ }),
114
+ }
115
+ }
116
+
117
+ const timeKind = config.timeKind || resolveTimeKind(scene, body)
118
+ const time = body.time ?? {}
119
+ const query = body.query ?? {}
120
+ const filters = body.filters ?? {}
121
+ const page = body.page ?? {}
122
+ const sort = body.sort ?? {}
123
+ const fallbackTime = defaultTimeRange()
124
+ const keywords = query.keywords ?? ''
125
+ const hasKeywords = typeof keywords === 'string' && keywords.trim() !== ''
126
+ const searchMode = query.search_mode ?? 'keyword'
127
+ const defaultSortField =
128
+ searchMode === 'keyword' && hasKeywords ? '_score' : config.defaultSortField
129
+
130
+ const publishStart =
131
+ timeKind === 'publish'
132
+ ? normalizeDateTime(time.publish_start || time.start || fallbackTime.start)
133
+ : normalizeDateTime(time.publish_start || '')
134
+ const publishEnd =
135
+ timeKind === 'publish'
136
+ ? normalizeDateTime(time.publish_end || time.end || fallbackTime.end)
137
+ : normalizeDateTime(time.publish_end || '')
138
+ const pushStart =
139
+ timeKind === 'push'
140
+ ? normalizeDateTime(time.push_start || time.start || fallbackTime.start)
141
+ : normalizeDateTime(time.push_start || '')
142
+ const pushEnd =
143
+ timeKind === 'push'
144
+ ? normalizeDateTime(time.push_end || time.end || fallbackTime.end)
145
+ : normalizeDateTime(time.push_end || '')
146
+
147
+ const payload = cleanObject({
148
+ publish_time_start: publishStart,
149
+ publish_time_end: publishEnd,
150
+ push_time_start: pushStart,
151
+ push_time_end: pushEnd,
152
+ keywords,
153
+ article_categories: filters.article_categories ?? [],
154
+ media_channels: filters.media_channels ?? [],
155
+ website_names: filters.website_names ?? [],
156
+ sentiment: filters.sentiment ?? [],
157
+ risk_levels: filters.risk_levels ?? [],
158
+ media_tags: filters.media_tags ?? [],
159
+ search_fields: query.search_fields ?? [],
160
+ article_type: filters.article_type ?? [],
161
+ article_id_list: filters.article_id_list ?? [],
162
+ article_status: filters.article_status ?? '',
163
+ status: filters.status ?? '',
164
+ is_valid:
165
+ center === 'warning'
166
+ ? filters.is_valid ?? ''
167
+ : filters.is_valid === undefined
168
+ ? 'Valid'
169
+ : filters.is_valid,
170
+ third_party: filters.third_party ?? [],
171
+ third_party_id: filters.third_party_id ?? null,
172
+ sort_field: sort.field ?? defaultSortField,
173
+ sort_order: sort.order ?? 'desc',
174
+ keywords_must_include: query.keywords_must_include ?? [],
175
+ keywords_should_include: query.keywords_should_include ?? [],
176
+ keywords_must_exclude: query.keywords_must_exclude ?? [],
177
+ page: page.page ?? 1,
178
+ size: page.size ?? 10,
179
+ search_mode: searchMode,
180
+ semantic_query: query.semantic_query ?? null,
181
+ semantic_top_n: query.semantic_top_n ?? null,
182
+ collapse_field: filters.collapse_field ?? '',
183
+ review_status:
184
+ center === 'warning' ? filters.review_status ?? 'pushed' : filters.review_status ?? null,
185
+ reviewer: filters.reviewer ?? null,
186
+ is_similar_collapse: Boolean(filters.is_similar_collapse),
187
+ topic_id: filters.topic_id,
188
+ warning_cluster_id: filters.warning_cluster_id,
189
+ exclude_ids: filters.exclude_ids ?? [],
190
+ })
191
+
192
+ return {
193
+ scene,
194
+ center,
195
+ stats,
196
+ upstreamPath: config.upstreamPath,
197
+ timeKind,
198
+ payload,
199
+ dryRun: Boolean(body.output?.dry_run),
200
+ }
201
+ }
@@ -0,0 +1,308 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createServer } from 'node:http'
4
+ import { mkdir, writeFile } from 'node:fs/promises'
5
+ import { resolveUpstreamBaseUrl } from '../core/config.mjs'
6
+ import { ErrorCode } from '../core/errors.mjs'
7
+ import { fail, ok, sendJson } from '../core/response.mjs'
8
+ import { logFile, pidFile, runtimeDir } from '../core/runtime-paths.mjs'
9
+ import { getSessionState, revokeSession, syncBrowserSession } from '../core/session-store.mjs'
10
+ import { availableCommands } from '../data/commands.mjs'
11
+ import { normalizeSearchPayload } from '../search/normalize-payload.mjs'
12
+
13
+ const host = '127.0.0.1'
14
+ const preferredPort = Number(process.env.LINGRUI_AGENT_PORT ?? 38100)
15
+ let activePort = preferredPort
16
+ const upstreamBaseUrl = resolveUpstreamBaseUrl()
17
+ const upstreamTimeoutMs = Number(process.env.LINGRUI_UPSTREAM_TIMEOUT_MS ?? 15000)
18
+
19
+ function isLoopback(req) {
20
+ const address = req.socket.remoteAddress
21
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
22
+ }
23
+
24
+ async function requireActiveSession() {
25
+ const { state, session } = await getSessionState()
26
+ if (state === 'active') return { authorized: true, session }
27
+ if (state === 'revoked') return { authorized: false, code: ErrorCode.SESSION_REVOKED }
28
+ if (state === 'expired') return { authorized: false, code: ErrorCode.SESSION_EXPIRED }
29
+ return { authorized: false, code: ErrorCode.AUTH_REQUIRED }
30
+ }
31
+
32
+ async function readJson(req) {
33
+ const chunks = []
34
+ for await (const chunk of req) {
35
+ chunks.push(chunk)
36
+ }
37
+
38
+ if (!chunks.length) return {}
39
+ try {
40
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ async function callUpstream(path, payload, auth) {
47
+ if (!upstreamBaseUrl) {
48
+ return {
49
+ proxied: false,
50
+ reason: 'LINGRUI_UPSTREAM_BASE_URL_not_configured',
51
+ }
52
+ }
53
+
54
+ const token = auth.session?.token?.value
55
+ if (!token) {
56
+ return {
57
+ proxied: false,
58
+ reason: 'browser_token_not_available_in_memory',
59
+ }
60
+ }
61
+
62
+ const controller = new AbortController()
63
+ const timeout = setTimeout(() => controller.abort(), upstreamTimeoutMs)
64
+
65
+ try {
66
+ const response = await fetch(`${upstreamBaseUrl}${path}`, {
67
+ method: 'POST',
68
+ headers: {
69
+ 'content-type': 'application/json',
70
+ Authorization: `bearer ${token}`,
71
+ },
72
+ signal: controller.signal,
73
+ body: JSON.stringify(payload),
74
+ })
75
+
76
+ const data = await response.json()
77
+ return {
78
+ proxied: true,
79
+ status: response.status,
80
+ data,
81
+ }
82
+ } catch (error) {
83
+ const causeMessage =
84
+ error && typeof error === 'object' && 'cause' in error && error.cause instanceof Error
85
+ ? error.cause.message
86
+ : ''
87
+ return {
88
+ proxied: false,
89
+ reason: error?.name === 'AbortError' ? 'upstream_request_timeout' : 'upstream_request_failed',
90
+ message: error instanceof Error ? error.message : String(error),
91
+ ...(causeMessage ? { cause: causeMessage } : {}),
92
+ }
93
+ } finally {
94
+ clearTimeout(timeout)
95
+ }
96
+ }
97
+
98
+ async function route(req, res) {
99
+ if (req.method === 'OPTIONS') {
100
+ return sendJson(res, 200, ok())
101
+ }
102
+
103
+ if (!isLoopback(req)) {
104
+ return sendJson(res, 403, fail(ErrorCode.LOOPBACK_ONLY))
105
+ }
106
+
107
+ const url = new URL(req.url ?? '/', `http://${host}:${activePort}`)
108
+ const key = `${req.method} ${url.pathname}`
109
+
110
+ try {
111
+ if (key === 'GET /health') {
112
+ const { state } = await getSessionState()
113
+ return sendJson(
114
+ res,
115
+ 200,
116
+ ok({
117
+ service: 'lingrui-local-agent',
118
+ status: 'online',
119
+ authState: state,
120
+ version: '0.1.0',
121
+ port: activePort,
122
+ upstreamConfigured: Boolean(upstreamBaseUrl),
123
+ upstreamBaseUrl: upstreamBaseUrl || null,
124
+ }),
125
+ )
126
+ }
127
+
128
+ if (key === 'GET /status') {
129
+ const { state, session } = await getSessionState()
130
+ return sendJson(
131
+ res,
132
+ 200,
133
+ ok({
134
+ service: 'lingrui-cli',
135
+ status: 'online',
136
+ loggedIn: state === 'active',
137
+ authState: state,
138
+ user: state === 'active' ? session?.user ?? null : null,
139
+ port: activePort,
140
+ }),
141
+ )
142
+ }
143
+
144
+ if (key === 'POST /auth/sync' || key === 'POST /auth/reconnect') {
145
+ const payload = await readJson(req)
146
+ if (!payload) {
147
+ return sendJson(res, 400, fail(ErrorCode.INVALID_PARAMS, { field: 'body' }))
148
+ }
149
+ if (!payload?.token) {
150
+ return sendJson(res, 400, fail(ErrorCode.INVALID_PARAMS, { field: 'token' }))
151
+ }
152
+
153
+ const session = await syncBrowserSession(payload)
154
+ return sendJson(
155
+ res,
156
+ 200,
157
+ ok({
158
+ sessionId: session.sessionId,
159
+ expiresAt: session.expiresAt,
160
+ user: session.user,
161
+ scopes: session.scopes,
162
+ }),
163
+ )
164
+ }
165
+
166
+ if (key === 'GET /auth/whoami') {
167
+ const auth = await requireActiveSession()
168
+ if (!auth.authorized) return sendJson(res, 401, fail(auth.code))
169
+ return sendJson(
170
+ res,
171
+ 200,
172
+ ok({
173
+ user: auth.session.user,
174
+ scopes: auth.session.scopes,
175
+ expiresAt: auth.session.expiresAt,
176
+ }),
177
+ )
178
+ }
179
+
180
+ if (key === 'GET /commands/list') {
181
+ const auth = await requireActiveSession()
182
+ if (!auth.authorized) return sendJson(res, 401, fail(auth.code))
183
+ return sendJson(
184
+ res,
185
+ 200,
186
+ ok({
187
+ items: availableCommands,
188
+ total: availableCommands.length,
189
+ }),
190
+ )
191
+ }
192
+
193
+ if (key === 'POST /auth/logout') {
194
+ await revokeSession()
195
+ console.log('[auth] browser signed out, local session revoked')
196
+ return sendJson(res, 200, ok({ status: 'revoked' }))
197
+ }
198
+
199
+ if (key === 'POST /skills/check') {
200
+ const auth = await requireActiveSession()
201
+ if (!auth.authorized) return sendJson(res, 401, fail(auth.code))
202
+ return sendJson(
203
+ res,
204
+ 200,
205
+ ok({
206
+ allowed: true,
207
+ status: 'logged_in',
208
+ user: auth.session.user,
209
+ }),
210
+ )
211
+ }
212
+
213
+ if (key === 'POST /api/search') {
214
+ const auth = await requireActiveSession()
215
+ if (!auth.authorized) return sendJson(res, 401, fail(auth.code))
216
+
217
+ const body = await readJson(req)
218
+ const normalized = normalizeSearchPayload(body)
219
+ if (normalized.error) return sendJson(res, 400, normalized.error)
220
+
221
+ if (!normalized.dryRun) {
222
+ const upstream = await callUpstream(normalized.upstreamPath, normalized.payload, auth)
223
+ if (upstream.proxied) {
224
+ return sendJson(
225
+ res,
226
+ 200,
227
+ ok({
228
+ scene: normalized.scene,
229
+ center: normalized.center,
230
+ stats: normalized.stats,
231
+ upstream_path: normalized.upstreamPath,
232
+ upstream_status: upstream.status,
233
+ result: upstream.data,
234
+ }),
235
+ )
236
+ }
237
+
238
+ return sendJson(
239
+ res,
240
+ 200,
241
+ ok({
242
+ scene: normalized.scene,
243
+ center: normalized.center,
244
+ stats: normalized.stats,
245
+ upstream_path: normalized.upstreamPath,
246
+ proxied: false,
247
+ reason: upstream.reason,
248
+ ...(upstream.status ? { upstream_status: upstream.status } : {}),
249
+ ...(upstream.message ? { message: upstream.message } : {}),
250
+ ...(upstream.cause ? { cause: upstream.cause } : {}),
251
+ payload: normalized.payload,
252
+ }),
253
+ )
254
+ }
255
+
256
+ return sendJson(
257
+ res,
258
+ 200,
259
+ ok({
260
+ scene: normalized.scene,
261
+ center: normalized.center,
262
+ stats: normalized.stats,
263
+ upstream_path: normalized.upstreamPath,
264
+ dry_run: true,
265
+ payload: normalized.payload,
266
+ }),
267
+ )
268
+ }
269
+
270
+ return sendJson(res, 404, fail(ErrorCode.NOT_FOUND, { path: url.pathname }))
271
+ } catch (error) {
272
+ return sendJson(
273
+ res,
274
+ 500,
275
+ fail(ErrorCode.INTERNAL_ERROR, {
276
+ message: error instanceof Error ? error.message : 'unknown error',
277
+ }),
278
+ )
279
+ }
280
+ }
281
+
282
+ const server = createServer(route)
283
+
284
+ function listenOnPort(portToTry) {
285
+ server.listen(portToTry, host)
286
+ }
287
+
288
+ server.on('error', (error) => {
289
+ if (error?.code === 'EADDRINUSE' && activePort < preferredPort + 20) {
290
+ const nextPort = activePort + 1
291
+ console.warn(`Port ${activePort} is in use, trying ${nextPort}...`)
292
+ activePort = nextPort
293
+ listenOnPort(activePort)
294
+ return
295
+ }
296
+
297
+ throw error
298
+ })
299
+
300
+ server.on('listening', async () => {
301
+ await mkdir(runtimeDir, { recursive: true })
302
+ await writeFile(pidFile, String(process.pid), 'utf8')
303
+ console.log(`Lingrui local agent is running at http://${host}:${activePort}`)
304
+ console.log(`Runtime dir: ${runtimeDir}`)
305
+ console.log(`Log file: ${logFile}`)
306
+ })
307
+
308
+ listenOnPort(activePort)