dsh-plugin-show-me-data 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 +27 -0
- package/README.md +96 -0
- package/cordis.patch.yml +40 -0
- package/docs/01-product-effect.md +178 -0
- package/docs/02-architecture.md +275 -0
- package/docs/03-data-contracts.md +291 -0
- package/docs/04-sources.md +342 -0
- package/docs/05-ui-spec.md +167 -0
- package/docs/06-ai-layer.md +194 -0
- package/docs/07-implementation-plan.md +399 -0
- package/docs/08-test-plan.md +133 -0
- package/docs/09-packaging-install.md +249 -0
- package/docs/10-kickoff-prompt.md +94 -0
- package/docs/11-decisions.md +203 -0
- package/docs/12-runtime-verified.md +115 -0
- package/docs/13-acceptance.md +153 -0
- package/docs/14-progress.md +150 -0
- package/docs/15-publish.md +185 -0
- package/lib/app/ai-deterministic.js +327 -0
- package/lib/app/ai-validate.js +284 -0
- package/lib/app/ai.js +440 -0
- package/lib/app/health.js +77 -0
- package/lib/app/overview.js +349 -0
- package/lib/app/propose-indicator.js +122 -0
- package/lib/app/refresh.js +251 -0
- package/lib/app/series-view.js +195 -0
- package/lib/app/watchlist.js +102 -0
- package/lib/client.js +4322 -0
- package/lib/core/ai/prompts.js +213 -0
- package/lib/core/chart/axis.js +133 -0
- package/lib/core/chart/bar.js +58 -0
- package/lib/core/chart/candle.js +216 -0
- package/lib/core/chart/line.js +186 -0
- package/lib/core/chart/scale.js +132 -0
- package/lib/core/format.js +143 -0
- package/lib/core/indicators/catalog.js +1011 -0
- package/lib/core/indicators/resolve.js +196 -0
- package/lib/core/insight/digest.js +250 -0
- package/lib/core/insight/rank.js +115 -0
- package/lib/core/insight/related.js +90 -0
- package/lib/core/insight/rules.js +417 -0
- package/lib/core/stats/derive.js +123 -0
- package/lib/core/stats/series.js +465 -0
- package/lib/core/time/range.js +242 -0
- package/lib/core/types.js +478 -0
- package/lib/host/ai/discussion.js +559 -0
- package/lib/host/ai/dsh-llm-gateway.js +333 -0
- package/lib/host/config.js +194 -0
- package/lib/host/http/respond.js +165 -0
- package/lib/host/http/routes.js +689 -0
- package/lib/host/index.js +293 -0
- package/lib/host/infra/fs-repos.js +179 -0
- package/lib/host/infra/memory-fallback.js +64 -0
- package/lib/host/tools/define-tool.js +295 -0
- package/lib/host/tools/register.js +431 -0
- package/lib/host.js +7 -0
- package/lib/ports/clock.js +57 -0
- package/lib/ports/snapshot-repo.js +48 -0
- package/lib/sources/eastmoney-macro.js +197 -0
- package/lib/sources/eastmoney-quote.js +201 -0
- package/lib/sources/ecb.js +179 -0
- package/lib/sources/fred.js +207 -0
- package/lib/sources/http.js +136 -0
- package/lib/sources/ohlc.js +36 -0
- package/lib/sources/quote-cascade.js +177 -0
- package/lib/sources/registry.js +153 -0
- package/lib/sources/sina-cn.js +197 -0
- package/lib/sources/sina-us.js +187 -0
- package/lib/sources/tencent.js +158 -0
- package/lib/sources/us-treasury-rates.js +275 -0
- package/lib/sources/us-treasury.js +196 -0
- package/lib/sources/worldbank.js +170 -0
- package/package.json +69 -0
- package/src/app/ai-deterministic.js +327 -0
- package/src/app/ai-validate.js +284 -0
- package/src/app/ai.js +440 -0
- package/src/app/health.js +77 -0
- package/src/app/overview.js +349 -0
- package/src/app/propose-indicator.js +122 -0
- package/src/app/refresh.js +251 -0
- package/src/app/series-view.js +195 -0
- package/src/app/watchlist.js +102 -0
- package/src/client/api.js +323 -0
- package/src/client/components.js +1877 -0
- package/src/client/copy.js +368 -0
- package/src/client/index.js +169 -0
- package/src/client/store.js +219 -0
- package/src/core/ai/prompts.js +213 -0
- package/src/core/chart/axis.js +133 -0
- package/src/core/chart/bar.js +58 -0
- package/src/core/chart/candle.js +216 -0
- package/src/core/chart/line.js +186 -0
- package/src/core/chart/scale.js +132 -0
- package/src/core/format.js +143 -0
- package/src/core/indicators/catalog.js +1011 -0
- package/src/core/indicators/resolve.js +196 -0
- package/src/core/insight/digest.js +250 -0
- package/src/core/insight/rank.js +115 -0
- package/src/core/insight/related.js +90 -0
- package/src/core/insight/rules.js +417 -0
- package/src/core/stats/derive.js +123 -0
- package/src/core/stats/series.js +465 -0
- package/src/core/time/range.js +242 -0
- package/src/core/types.js +478 -0
- package/src/host/ai/discussion.js +559 -0
- package/src/host/ai/dsh-llm-gateway.js +333 -0
- package/src/host/config.js +194 -0
- package/src/host/http/respond.js +165 -0
- package/src/host/http/routes.js +689 -0
- package/src/host/index.js +293 -0
- package/src/host/infra/fs-repos.js +179 -0
- package/src/host/infra/memory-fallback.js +64 -0
- package/src/host/tools/define-tool.js +295 -0
- package/src/host/tools/register.js +431 -0
- package/src/ports/clock.js +57 -0
- package/src/ports/snapshot-repo.js +48 -0
- package/src/sources/eastmoney-macro.js +197 -0
- package/src/sources/eastmoney-quote.js +201 -0
- package/src/sources/ecb.js +179 -0
- package/src/sources/fred.js +207 -0
- package/src/sources/http.js +136 -0
- package/src/sources/ohlc.js +36 -0
- package/src/sources/quote-cascade.js +177 -0
- package/src/sources/registry.js +153 -0
- package/src/sources/sina-cn.js +197 -0
- package/src/sources/sina-us.js +187 -0
- package/src/sources/tencent.js +158 -0
- package/src/sources/us-treasury-rates.js +275 -0
- package/src/sources/us-treasury.js +196 -0
- package/src/sources/worldbank.js +170 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discussion sessions — "talk about this indicator in a real DSH session".
|
|
3
|
+
*
|
|
4
|
+
* The panel's inline AI block answers one question from the digest and stops
|
|
5
|
+
* there: it cannot hold a conversation, change model, or be returned to later.
|
|
6
|
+
* When the deployment has an agent loop (`ctx.agents`) and a default model, this
|
|
7
|
+
* gateway opens a **real session** for the topic instead, hands it the panel's
|
|
8
|
+
* digest as context, asks the user's question, and returns the session id so the
|
|
9
|
+
* GUI can switch to it. From that point the conversation is an ordinary session:
|
|
10
|
+
* full composer, history, model picker, sidebar entry.
|
|
11
|
+
*
|
|
12
|
+
* Design notes:
|
|
13
|
+
* - every contribution is additive: the digest arrives as *context* (no wake), so
|
|
14
|
+
* the session's first user-visible message is the user's own question;
|
|
15
|
+
* - the caller can ask for a fresh session per question or reuse a topic session;
|
|
16
|
+
* - the gateway never throws for a missing capability — it returns a structured
|
|
17
|
+
* `unavailable` result so the panel can say what to do instead.
|
|
18
|
+
*
|
|
19
|
+
* @module host/ai/discussion
|
|
20
|
+
*/
|
|
21
|
+
import { fingerprint } from '../../core/insight/rank.js'
|
|
22
|
+
import { buildDefaultQuestion } from '../../core/ai/prompts.js'
|
|
23
|
+
|
|
24
|
+
/** How long to wait for the seeded answer before returning with `pending: true`. */
|
|
25
|
+
export const REPLY_TIMEOUT_MS = 45_000
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build one identified user-role message for the session.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} text - message text.
|
|
31
|
+
* @param {{ kind: 'plugin', plugin: string }|{ kind: 'user' }} source - producer tag.
|
|
32
|
+
* @param {string} seed - identity seed.
|
|
33
|
+
* @returns {object} `UserMessage`.
|
|
34
|
+
*/
|
|
35
|
+
export function buildMessage(text, source, seed) {
|
|
36
|
+
return {
|
|
37
|
+
id: `smd-${fingerprint(`${seed}|${text.length}|${text.slice(0, 64)}`)}`,
|
|
38
|
+
role: 'user',
|
|
39
|
+
content: [{ type: 'text', text }],
|
|
40
|
+
source,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compose the context block that gives a fresh session the panel's data.
|
|
46
|
+
*
|
|
47
|
+
* The session is a *real* DSH session with the plugin's own `data_*` tools, so the
|
|
48
|
+
* context has two jobs: publish the numbers behind the discussion (so nothing is
|
|
49
|
+
* quoted from memory), and say what else the panel holds — a session that believes
|
|
50
|
+
* the panel has one indicator refuses questions it could have answered.
|
|
51
|
+
*
|
|
52
|
+
* @param {object} input - context input.
|
|
53
|
+
* @param {{ zh: string, en?: string }} input.label - indicator label.
|
|
54
|
+
* @param {string} input.indicatorId - indicator id.
|
|
55
|
+
* @param {string} input.digest - the digest text (docs/06 §2.3).
|
|
56
|
+
* @param {{ from: string, to: string }} [input.range] - the panel's range.
|
|
57
|
+
* @param {string} [input.inventory] - one-line-per-indicator inventory of the rest of the panel.
|
|
58
|
+
* @returns {string} context text.
|
|
59
|
+
*/
|
|
60
|
+
export function buildContextText({ label, indicatorId, digest, range, inventory }) {
|
|
61
|
+
const lines = []
|
|
62
|
+
lines.push('[数据雷达] 本会话的上下文来自插件面板,不是用户输入。')
|
|
63
|
+
lines.push(`讨论主题:${label?.zh ?? indicatorId}(${indicatorId})`)
|
|
64
|
+
if (range !== undefined) lines.push(`面板区间:${range.from} .. ${range.to}`)
|
|
65
|
+
lines.push('')
|
|
66
|
+
lines.push('只引用面板给出的数字(下面的摘要,或你用面板工具取回的数据);不足时说明数据不足,不要凭记忆补充数值:')
|
|
67
|
+
lines.push('')
|
|
68
|
+
// An empty digest is "no data", not an empty block: the session must be told
|
|
69
|
+
// that the panel had nothing, or it will invent context.
|
|
70
|
+
const body = typeof digest === 'string' ? digest.trim() : ''
|
|
71
|
+
lines.push(body === '' ? '(无数据:面板当前没有可用的指标观测)' : body)
|
|
72
|
+
const rest = typeof inventory === 'string' ? inventory.trim() : ''
|
|
73
|
+
if (rest !== '') {
|
|
74
|
+
lines.push('')
|
|
75
|
+
lines.push(rest)
|
|
76
|
+
}
|
|
77
|
+
lines.push('')
|
|
78
|
+
lines.push('你可以直接用面板工具向数据雷达取数:data_search(搜索指标)、data_series(取某指标序列)、data_overview(面板总览)、data_digest(区间摘要)、data_health(数据源状态)。清单中的任何 id 都可以用 data_series 取完整序列。')
|
|
79
|
+
return lines.join('\n')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read the assistant's text out of a session's event log.
|
|
84
|
+
*
|
|
85
|
+
* @param {Array<{ type?: string, data?: any }>} events - session events.
|
|
86
|
+
* @returns {{ text: string, turn: number|undefined }} the last assistant text.
|
|
87
|
+
*/
|
|
88
|
+
export function extractAssistantText(events) {
|
|
89
|
+
let text = ''
|
|
90
|
+
let turn
|
|
91
|
+
for (const event of events ?? []) {
|
|
92
|
+
if (event?.type !== 'assistant/message') continue
|
|
93
|
+
const blocks = event.data?.message?.content
|
|
94
|
+
if (!Array.isArray(blocks)) continue
|
|
95
|
+
const joined = blocks
|
|
96
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
97
|
+
.map((block) => block.text)
|
|
98
|
+
.join('')
|
|
99
|
+
.trim()
|
|
100
|
+
if (joined === '') continue
|
|
101
|
+
text = joined
|
|
102
|
+
turn = event.data?.turn
|
|
103
|
+
}
|
|
104
|
+
return { text, turn }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detect a turn that *failed* rather than one that simply had nothing to say.
|
|
109
|
+
*
|
|
110
|
+
* An LLM failure (a missing credential, an upstream 5xx, a refused request)
|
|
111
|
+
* does not reject `whenIdle()`: the loop records it and goes idle. A gateway
|
|
112
|
+
* that only reads text therefore reports success with an empty reply, and the
|
|
113
|
+
* GUI shows an empty answer as if the model had answered nothing — which is
|
|
114
|
+
* exactly how "why is there no output" becomes unanswerable from the panel.
|
|
115
|
+
*
|
|
116
|
+
* @param {Array<{ type?: string, data?: any }>} events - events of the observed turn.
|
|
117
|
+
* @returns {{ message: string, code: string|undefined }|undefined} the failure.
|
|
118
|
+
*/
|
|
119
|
+
export function extractTurnFailure(events) {
|
|
120
|
+
let failure
|
|
121
|
+
for (const event of events ?? []) {
|
|
122
|
+
// Preferred source: the terminating chunk names provider, code and message.
|
|
123
|
+
if (event?.type === 'assistant/chunk' && event.data?.chunk?.type === 'finish') {
|
|
124
|
+
const reason = event.data.chunk.reason
|
|
125
|
+
if (reason?.kind === 'error' && typeof reason.failure?.message === 'string') {
|
|
126
|
+
failure = { message: reason.failure.message, code: reason.failure.code }
|
|
127
|
+
}
|
|
128
|
+
if (reason?.kind === 'aborted') {
|
|
129
|
+
failure = { message: '模型请求被中止(aborted)', code: reason.failure?.code }
|
|
130
|
+
}
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (event?.type !== 'turn/end') continue
|
|
134
|
+
const reason = event.data?.reason
|
|
135
|
+
if (reason?.kind !== 'error' && reason?.kind !== 'aborted') continue
|
|
136
|
+
const detail = reason.error ?? reason.failure ?? {}
|
|
137
|
+
const message = typeof detail.message === 'string'
|
|
138
|
+
? detail.message
|
|
139
|
+
: reason.kind === 'aborted' ? '模型请求被中止(aborted)' : 'turn ended with error'
|
|
140
|
+
failure = { message, code: detail.code }
|
|
141
|
+
}
|
|
142
|
+
return failure
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create the discussion gateway.
|
|
147
|
+
*
|
|
148
|
+
* @param {object} deps - dependencies.
|
|
149
|
+
* @param {object} deps.ctx - host context (for `agents`, `sessions`, `agentDefaultModel`).
|
|
150
|
+
* @param {import('../../ports/clock.js').Clock} deps.clock - clock.
|
|
151
|
+
* @param {(msg: string, meta?: object) => void} [deps.log] - logger.
|
|
152
|
+
* @param {{ timeoutMs?: number }} [deps.options] - tuning.
|
|
153
|
+
* @returns {object} gateway.
|
|
154
|
+
*/
|
|
155
|
+
export function createDiscussionGateway({ ctx, clock, log = () => {}, options = {} }) {
|
|
156
|
+
const timeoutMs = options.timeoutMs ?? REPLY_TIMEOUT_MS
|
|
157
|
+
const cwd = options.cwd
|
|
158
|
+
// A monotonically increasing counter keeps two discussions opened in the same
|
|
159
|
+
// millisecond from colliding on one session id (the clock alone is not unique).
|
|
160
|
+
let sequence = 0
|
|
161
|
+
/** @type {Map<string, { sessionId: string, createdAt: string, label: string, turns: number }>} */
|
|
162
|
+
const topics = new Map()
|
|
163
|
+
/** @type {Map<string, object>} */
|
|
164
|
+
const agentsByTopic = new Map()
|
|
165
|
+
/** @type {Map<string, { agent: object, askedAt: number, from: number }>} */
|
|
166
|
+
const bySession = new Map()
|
|
167
|
+
/** @type {Array<() => Promise<void>>} */
|
|
168
|
+
const live = []
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether this deployment can open sessions at all.
|
|
172
|
+
*
|
|
173
|
+
* @returns {{ available: boolean, reason?: string }} capability.
|
|
174
|
+
*/
|
|
175
|
+
function capability() {
|
|
176
|
+
if (ctx.get('agents') === undefined) return { available: false, reason: '本部署没有 agent 运行时(ctx.agents 不可用),无法开启独立会话。' }
|
|
177
|
+
if (ctx.get('agentDefaultModel') === undefined) return { available: false, reason: '本部署没有默认模型选择(ctx.agentDefaultModel 不可用),无法开启独立会话。' }
|
|
178
|
+
return { available: true }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Compose the session metadata.
|
|
183
|
+
*
|
|
184
|
+
* A discussion is a **user-visible session of its own**, not a delegated
|
|
185
|
+
* subagent: it carries the workspace and the preset but no parent lineage, so
|
|
186
|
+
* it appears in the sidebar beside other sessions and can be reopened later.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} [requester] - the requesting *agent*, when the caller knows it.
|
|
189
|
+
* @param {string} [overrideCwd] - explicit workspace for the new session.
|
|
190
|
+
* @returns {object} session meta.
|
|
191
|
+
*/
|
|
192
|
+
function sessionMeta(requester, overrideCwd) {
|
|
193
|
+
const requesterCwd = requester?.session?.header?.cwd
|
|
194
|
+
const sessionCwd = typeof overrideCwd === 'string' && overrideCwd !== ''
|
|
195
|
+
? overrideCwd
|
|
196
|
+
: typeof requesterCwd === 'string' && requesterCwd !== '' ? requesterCwd : cwd
|
|
197
|
+
// Read from the requesting *agent's* scope, not from a session object: the
|
|
198
|
+
// standing mount that answers "which preset is this?" lives on the agent ctx.
|
|
199
|
+
const preset = requester?.ctx === undefined ? undefined : ctx.get('agentPresets')?.composedPreset?.(requester.ctx)
|
|
200
|
+
return {
|
|
201
|
+
...(typeof sessionCwd === 'string' && sessionCwd !== '' ? { cwd: sessionCwd } : {}),
|
|
202
|
+
...(typeof preset === 'string' && preset !== '' ? { agentPreset: preset } : {}),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Join the new agent to a composition, so it is not born into an empty world.
|
|
208
|
+
*
|
|
209
|
+
* An agent that joins no preset resolves its tools, prompt sections and skills
|
|
210
|
+
* against the empty global layer: the session sees only the tools registered on
|
|
211
|
+
* the host composition — this plugin's ten `data_*` tools — with no filesystem,
|
|
212
|
+
* no shell, no skills. An iteration session created that way told the reader it
|
|
213
|
+
* *could not edit the plugin* because it had no file tools, which is true.
|
|
214
|
+
*
|
|
215
|
+
* The requester's own composition is joined when the caller named the
|
|
216
|
+
* requesting session (the panel sends it), so the discussion runs on the same
|
|
217
|
+
* preset, tools and prompt the reader is already using. Otherwise the
|
|
218
|
+
* deployment's default preset is mounted.
|
|
219
|
+
*
|
|
220
|
+
* A failure here is logged and swallowed: composition is worth having, but it
|
|
221
|
+
* is never worth losing the session the reader asked for.
|
|
222
|
+
*
|
|
223
|
+
* @param {object} [requester] - the requesting agent, when known.
|
|
224
|
+
* @returns {((agentCtx: object) => Promise<void>)|undefined} setup hook.
|
|
225
|
+
*/
|
|
226
|
+
function compositionSetup(requester) {
|
|
227
|
+
const presets = ctx.get('agentPresets')
|
|
228
|
+
if (presets === undefined) return undefined
|
|
229
|
+
if (typeof presets.composeFrom !== 'function' && typeof presets.mount !== 'function') return undefined
|
|
230
|
+
return async (agentCtx) => {
|
|
231
|
+
try {
|
|
232
|
+
if (requester?.ctx !== undefined && typeof presets.composeFrom === 'function') {
|
|
233
|
+
const joined = presets.composeFrom(agentCtx, requester.ctx)
|
|
234
|
+
if (joined !== undefined) {
|
|
235
|
+
log('discussion joined the requesting composition', { preset: joined })
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (typeof presets.mount === 'function') {
|
|
240
|
+
const mounted = await presets.mount(agentCtx)
|
|
241
|
+
log('discussion mounted the default composition', { preset: mounted?.id ?? presets.defaultId })
|
|
242
|
+
}
|
|
243
|
+
} catch (error) {
|
|
244
|
+
log('discussion joined no composition', { message: error?.message ?? String(error) })
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Open a discussion about one subject: an indicator, a notable item, or a group.
|
|
251
|
+
*
|
|
252
|
+
* The subject is identified by `topicKey` ('us.cpi.yoy', 'group:US', …) which
|
|
253
|
+
* becomes the session id and the reuse key; `indicatorId` alone still works, so
|
|
254
|
+
* the single-indicator path is unchanged. Only the *context* differs between
|
|
255
|
+
* callers, and the caller builds that — this gateway never guesses what a
|
|
256
|
+
* discussion is about.
|
|
257
|
+
*
|
|
258
|
+
* @param {object} request - request.
|
|
259
|
+
* @param {string} [request.indicatorId] - indicator id (the subject, when it is one).
|
|
260
|
+
* @param {string} [request.topicKey] - subject key; defaults to `indicatorId`.
|
|
261
|
+
* @param {{ zh: string, en?: string }} [request.label] - subject label.
|
|
262
|
+
* @param {string} request.digest - digest text handed to the session as context.
|
|
263
|
+
* @param {string} [request.question] - the user's first question.
|
|
264
|
+
* @param {{ from: string, to: string }} [request.range] - panel range.
|
|
265
|
+
* @param {'new'|'topic'} [request.mode] - a fresh session each time (`new`, default) or one session per subject (`topic`).
|
|
266
|
+
* @param {string} [request.sessionId] - the requesting session, for cwd/preset/tool inheritance.
|
|
267
|
+
* @param {string} [request.cwd] - explicit working directory for the new session
|
|
268
|
+
* (an iteration session has to start in the plugin workspace, not wherever the
|
|
269
|
+
* requesting session happens to be).
|
|
270
|
+
* @param {boolean} [request.ask] - `false` seeds the session and asks nothing.
|
|
271
|
+
* @returns {Promise<object>} `{ ok, sessionId, reply, pending, mode, error? }`.
|
|
272
|
+
*/
|
|
273
|
+
async function discuss(request = {}) {
|
|
274
|
+
const capabilityState = capability()
|
|
275
|
+
if (capabilityState.available !== true) {
|
|
276
|
+
return { ok: false, error: { kind: 'unavailable', detail: capabilityState.reason, retryable: false } }
|
|
277
|
+
}
|
|
278
|
+
const { label, digest, question, range } = request
|
|
279
|
+
const indicatorId = typeof request.topicKey === 'string' && request.topicKey !== '' ? request.topicKey : request.indicatorId
|
|
280
|
+
if (typeof indicatorId !== 'string' || indicatorId === '') {
|
|
281
|
+
return { ok: false, error: { kind: 'bad-request', detail: 'indicatorId (or topicKey) is required', retryable: false } }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const agents = ctx.get('agents')
|
|
285
|
+
const defaultModel = ctx.get('agentDefaultModel')
|
|
286
|
+
const sessions = ctx.get('sessions')
|
|
287
|
+
const selection = defaultModel.currentSelection()
|
|
288
|
+
if (selection?.provider === undefined || selection?.model === undefined) {
|
|
289
|
+
return { ok: false, error: { kind: 'unavailable', detail: '默认模型未配置(agentDefaultModel.currentSelection() 为空),无法开启独立会话。', retryable: false } }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Which session asked. The panel sends its own id, and that one lookup buys
|
|
293
|
+
// the discussion the reader's working directory, their preset, and therefore
|
|
294
|
+
// their tools — a session created without it joins no composition at all.
|
|
295
|
+
const requester = typeof request.sessionId === 'string' && request.sessionId !== '' && typeof agents.get === 'function'
|
|
296
|
+
? agents.get(request.sessionId)
|
|
297
|
+
: undefined
|
|
298
|
+
|
|
299
|
+
// A discussion with a context message but no question is a session where
|
|
300
|
+
// nothing happens: the context is parked in the inbox for a turn that never
|
|
301
|
+
// starts, so the GUI opens an empty session and the panel — polling for text
|
|
302
|
+
// that cannot exist — reports a model failure. The panel's buttons are
|
|
303
|
+
// labelled 「讨论」, so when the reader typed nothing we ask the subject's
|
|
304
|
+
// opening question for them and say which question that was.
|
|
305
|
+
// `ask: false` is the deliberate exception, for a caller that only wants the
|
|
306
|
+
// session seeded and will drive it itself.
|
|
307
|
+
const typed = typeof question === 'string' ? question.trim() : ''
|
|
308
|
+
const asked = typed === ''
|
|
309
|
+
? buildDefaultQuestion({ subject: request.subject ?? 'indicator', label: label?.zh ?? indicatorId })
|
|
310
|
+
: typed
|
|
311
|
+
const seedQuestion = request.ask === false ? '' : asked
|
|
312
|
+
// "The panel asked this" is only true when it actually did.
|
|
313
|
+
const autoQuestion = seedQuestion !== '' && typed === ''
|
|
314
|
+
|
|
315
|
+
// `topic` mode continues an existing discussion; `new` (the default) opens a
|
|
316
|
+
// fresh session for every question, which is what the panel's button says.
|
|
317
|
+
const reuse = request.mode === 'topic' && agentsByTopic.has(indicatorId)
|
|
318
|
+
const topic = reuse ? topics.get(indicatorId) : undefined
|
|
319
|
+
sequence += 1
|
|
320
|
+
const sessionId = topic?.sessionId ?? `session-smd-${clock.now().getTime().toString(36)}-${sequence.toString(36)}-${fingerprint(`${indicatorId}|${clock.now().toISOString()}|${sequence}`)}`
|
|
321
|
+
|
|
322
|
+
let agent
|
|
323
|
+
if (reuse) {
|
|
324
|
+
agent = agentsByTopic.get(indicatorId)
|
|
325
|
+
} else {
|
|
326
|
+
try {
|
|
327
|
+
const setup = compositionSetup(requester)
|
|
328
|
+
const handle = await agents.create({
|
|
329
|
+
sessionId,
|
|
330
|
+
meta: sessionMeta(requester, request.cwd),
|
|
331
|
+
agentOptions: { provider: selection.provider, model: selection.model },
|
|
332
|
+
...(setup === undefined ? {} : { setup }),
|
|
333
|
+
})
|
|
334
|
+
agent = handle.agent
|
|
335
|
+
agentsByTopic.set(indicatorId, agent)
|
|
336
|
+
live.push(async () => {
|
|
337
|
+
try {
|
|
338
|
+
await handle.dispose()
|
|
339
|
+
} catch {
|
|
340
|
+
// disposal is best-effort
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
} catch (error) {
|
|
344
|
+
log('discussion session creation failed', { indicatorId, message: error?.message ?? String(error) })
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
error: { kind: 'session-create-failed', detail: `无法创建会话:${error?.message ?? error}`, retryable: true },
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const contextText = buildContextText({ label, indicatorId, digest, range })
|
|
353
|
+
const wait = request.wait !== false
|
|
354
|
+
// The session's event log is not readable while the loop is starting a turn
|
|
355
|
+
// (observed: `session.events` is undefined between `followup()` and the turn
|
|
356
|
+
// boundary), so the cursor is taken defensively and only when it is used.
|
|
357
|
+
const eventsOf = () => (Array.isArray(agent.session?.events) ? agent.session.events : [])
|
|
358
|
+
let pending = false
|
|
359
|
+
let reply = ''
|
|
360
|
+
try {
|
|
361
|
+
// Registered before the turn so `answer()` can always find the session,
|
|
362
|
+
// including the context-only case: the panel polls as soon as it has an
|
|
363
|
+
// id, and a 404 there would leave "正在生成" on screen for good.
|
|
364
|
+
bySession.set(sessionId, { agent, askedAt: clock.now().getTime(), from: eventsOf().length, asked: seedQuestion !== '' })
|
|
365
|
+
if (seedQuestion !== '') {
|
|
366
|
+
// The context travels as a `pre-turn` message in the SAME turn as the
|
|
367
|
+
// question. A `between-turns` context message is only claimed when the
|
|
368
|
+
// session runs another turn, so a session whose first turn is this very
|
|
369
|
+
// question would otherwise see no panel data at all.
|
|
370
|
+
agent.send(
|
|
371
|
+
buildMessage(contextText, { kind: 'plugin', plugin: 'show-me-data' }, `ctx|${sessionId}`),
|
|
372
|
+
// `InboxTarget` is the string union 'next-turn' | 'next-step'.
|
|
373
|
+
'next-turn',
|
|
374
|
+
false,
|
|
375
|
+
)
|
|
376
|
+
const firstSeq = eventsOf().length
|
|
377
|
+
// The context message already counted toward the cursor; the answer is
|
|
378
|
+
// whatever the turn produces after it.
|
|
379
|
+
bySession.set(sessionId, { agent, askedAt: clock.now().getTime(), from: firstSeq, asked: true })
|
|
380
|
+
// A question the panel wrote for the reader is tagged as the plugin's, not
|
|
381
|
+
// as the reader's: the transcript must not claim the user typed it.
|
|
382
|
+
agent.followup(buildMessage(seedQuestion, autoQuestion ? { kind: 'plugin', plugin: 'show-me-data' } : { kind: 'user' }, `q|${sessionId}`))
|
|
383
|
+
if (wait) {
|
|
384
|
+
const settled = await withTimeout(agent.whenIdle(), timeoutMs)
|
|
385
|
+
// A timeout is not a failure: the turn is still running in a real
|
|
386
|
+
// session the caller can open, so answer 200 and say it is pending
|
|
387
|
+
// rather than turning a slow model into an error.
|
|
388
|
+
pending = settled === 'timeout'
|
|
389
|
+
const observed = eventsOf().slice(firstSeq)
|
|
390
|
+
const failure = settled === 'timeout' ? undefined : extractTurnFailure(observed)
|
|
391
|
+
if (failure !== undefined) {
|
|
392
|
+
// The session exists and is worth keeping: hand its id back so the
|
|
393
|
+
// GUI can still switch there, but never pretend the turn succeeded.
|
|
394
|
+
log('discussion turn failed', { indicatorId, sessionId, code: failure.code, message: failure.message })
|
|
395
|
+
return {
|
|
396
|
+
ok: false,
|
|
397
|
+
sessionId,
|
|
398
|
+
question: asked,
|
|
399
|
+
autoQuestion,
|
|
400
|
+
error: {
|
|
401
|
+
kind: 'session-turn-failed',
|
|
402
|
+
detail: `会话已创建,但模型请求失败:${failure.message}`,
|
|
403
|
+
retryable: true,
|
|
404
|
+
},
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const { text } = extractAssistantText(observed)
|
|
408
|
+
reply = text
|
|
409
|
+
} else {
|
|
410
|
+
// The caller asked not to wait: the turn runs in this session, and the
|
|
411
|
+
// GUI it opens will show the answer as it streams.
|
|
412
|
+
pending = true
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
// `ask: false`: keep the panel data ready for whatever the reader asks
|
|
416
|
+
// first, so opening a seeded session and typing there has the context.
|
|
417
|
+
// Nothing runs, so the answer state is `idle`, never "done and empty".
|
|
418
|
+
agent.send(
|
|
419
|
+
buildMessage(contextText, { kind: 'plugin', plugin: 'show-me-data' }, `ctx|${sessionId}`),
|
|
420
|
+
'next-turn',
|
|
421
|
+
false,
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
} catch (error) {
|
|
425
|
+
// The session exists but driving its first turn failed: say so with the
|
|
426
|
+
// cause instead of letting it surface as a bare 500.
|
|
427
|
+
log('discussion turn failed', { indicatorId, sessionId, message: error?.message ?? String(error) })
|
|
428
|
+
return {
|
|
429
|
+
ok: false,
|
|
430
|
+
sessionId,
|
|
431
|
+
question: asked,
|
|
432
|
+
autoQuestion,
|
|
433
|
+
error: {
|
|
434
|
+
kind: 'session-turn-failed',
|
|
435
|
+
detail: `会话已创建,但首轮提问失败:${error?.message ?? error}`,
|
|
436
|
+
retryable: true,
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
topics.set(indicatorId, {
|
|
442
|
+
sessionId,
|
|
443
|
+
createdAt: clock.now().toISOString(),
|
|
444
|
+
label: label?.zh ?? indicatorId,
|
|
445
|
+
turns: (topic?.turns ?? 0) + (seedQuestion === '' ? 0 : 1),
|
|
446
|
+
})
|
|
447
|
+
// Persist the discussion so it survives a restart and shows up in the
|
|
448
|
+
// session list. The loop's checkpoint policy may still be settling when
|
|
449
|
+
// `whenIdle()` resolves, so the flush is retried briefly: a session that
|
|
450
|
+
// exists but was never written is worse than one extra read.
|
|
451
|
+
if (sessions !== undefined && sessions.flush !== undefined) {
|
|
452
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
453
|
+
try {
|
|
454
|
+
const flushed = await sessions.flush(agent.session)
|
|
455
|
+
if (flushed !== false) break
|
|
456
|
+
} catch (error) {
|
|
457
|
+
log('discussion flush failed', { attempt, message: error?.message ?? String(error) })
|
|
458
|
+
}
|
|
459
|
+
await new Promise((resolve) => {
|
|
460
|
+
const timer = setTimeout(resolve, 150 * attempt)
|
|
461
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
log('discussion opened', { indicatorId, sessionId, mode: reuse ? 'topic' : 'new', replyChars: reply.length, pending })
|
|
467
|
+
return {
|
|
468
|
+
ok: true,
|
|
469
|
+
sessionId,
|
|
470
|
+
mode: reuse ? 'topic' : 'new',
|
|
471
|
+
reply,
|
|
472
|
+
pending,
|
|
473
|
+
question: asked,
|
|
474
|
+
autoQuestion,
|
|
475
|
+
provider: selection.provider,
|
|
476
|
+
model: selection.model,
|
|
477
|
+
topic: { turns: topics.get(indicatorId).turns },
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Collect the answer to a discussion that was opened with `wait: false`.
|
|
483
|
+
*
|
|
484
|
+
* The panel opens the session and switches to it immediately, so its own
|
|
485
|
+
* request cannot also wait for the turn. This is how the panel comes back for
|
|
486
|
+
* the answer a few seconds later instead of leaving the block empty until the
|
|
487
|
+
* reader notices the new session.
|
|
488
|
+
*
|
|
489
|
+
* @param {string} sessionId - session id returned by `discuss()`.
|
|
490
|
+
* @returns {{ status: 'unknown'|'idle'|'pending'|'done'|'failed', reply?: string, error?: { kind: string, detail: string }, eventCount?: number }} state.
|
|
491
|
+
*/
|
|
492
|
+
function answer(sessionId) {
|
|
493
|
+
const entry = bySession.get(sessionId)
|
|
494
|
+
if (entry === undefined) return { status: 'unknown' }
|
|
495
|
+
const events = Array.isArray(entry.agent.session?.events) ? entry.agent.session.events : []
|
|
496
|
+
const observed = events.slice(entry.from)
|
|
497
|
+
const { text } = extractAssistantText(observed)
|
|
498
|
+
const failure = extractTurnFailure(observed)
|
|
499
|
+
if (failure !== undefined) {
|
|
500
|
+
return { status: 'failed', error: { kind: 'session-turn-failed', detail: failure.message }, eventCount: events.length }
|
|
501
|
+
}
|
|
502
|
+
// A seeded session nobody asked anything in has no answer to wait for. It is
|
|
503
|
+
// 'idle', not 'done with empty text': reporting the latter made the panel
|
|
504
|
+
// diagnose a model failure for a session that was simply never asked.
|
|
505
|
+
if (entry.asked !== true) return { status: 'idle', reply: text, eventCount: events.length }
|
|
506
|
+
const active = entry.agent.status === 'running' || entry.agent.status?.kind === 'running'
|
|
507
|
+
// While the turn is still running, text may only be the model narrating a tool
|
|
508
|
+
// call ("let me inspect the workspace source of truth") — reporting that as
|
|
509
|
+
// the answer left the panel showing a half-sentence instead of the conclusion.
|
|
510
|
+
// The final text is read once the agent is idle again.
|
|
511
|
+
if (active) return { status: 'pending', eventCount: events.length }
|
|
512
|
+
return { status: 'done', reply: text, eventCount: events.length }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The sessions this plugin opened in this process.
|
|
517
|
+
*
|
|
518
|
+
* @returns {Array<{ indicatorId: string, sessionId: string, createdAt: string, turns: number }>} topics.
|
|
519
|
+
*/
|
|
520
|
+
function list() {
|
|
521
|
+
return [...topics.entries()].map(([indicatorId, entry]) => ({ indicatorId, ...entry }))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Dispose every session this plugin opened.
|
|
526
|
+
*
|
|
527
|
+
* @returns {Promise<void>} completion.
|
|
528
|
+
*/
|
|
529
|
+
async function dispose() {
|
|
530
|
+
const pendingDisposals = live.splice(0, live.length)
|
|
531
|
+
await Promise.allSettled(pendingDisposals.map((disposeOne) => disposeOne()))
|
|
532
|
+
topics.clear()
|
|
533
|
+
agentsByTopic.clear()
|
|
534
|
+
bySession.clear()
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return { discuss, answer, list, dispose, capability }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Await a promise with a budget.
|
|
542
|
+
*
|
|
543
|
+
* @param {Promise<any>} promise - promise to await.
|
|
544
|
+
* @param {number} ms - budget.
|
|
545
|
+
* @returns {Promise<'settled'|'timeout'>} outcome.
|
|
546
|
+
*/
|
|
547
|
+
async function withTimeout(promise, ms) {
|
|
548
|
+
let timer
|
|
549
|
+
try {
|
|
550
|
+
return await Promise.race([
|
|
551
|
+
promise.then(() => 'settled'),
|
|
552
|
+
new Promise((resolve) => {
|
|
553
|
+
timer = setTimeout(() => resolve('timeout'), ms)
|
|
554
|
+
}),
|
|
555
|
+
])
|
|
556
|
+
} finally {
|
|
557
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
558
|
+
}
|
|
559
|
+
}
|