pi-qtools 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 +159 -0
- package/extensions/qtools.ts +1528 -0
- package/package.json +34 -0
|
@@ -0,0 +1,1528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-qtools — bridge the Qwen Token Plan into pi: built-in server-side tools plus
|
|
3
|
+
* the image / video / audio generation models that are not chat models.
|
|
4
|
+
*
|
|
5
|
+
* Mapped empirically against the live endpoint:
|
|
6
|
+
* https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
|
|
7
|
+
*
|
|
8
|
+
* TWO API surfaces, different capabilities:
|
|
9
|
+
*
|
|
10
|
+
* A) /chat/completions (what pi itself uses)
|
|
11
|
+
* enable_search + search_options.search_strategy: "turbo"|"max" coexist with
|
|
12
|
+
* pi's function tools -> injected into the main loop.
|
|
13
|
+
* search_strategy "agent" and enable_code_interpreter flip Alibaba "Agent
|
|
14
|
+
* mode", which REJECTS any `tools` array -> cannot run in pi's loop.
|
|
15
|
+
*
|
|
16
|
+
* B) /responses — built-in tools DO coexist with function tools here, and it
|
|
17
|
+
* returns structured traces: web_search_call (query+sources),
|
|
18
|
+
* code_interpreter_call (code+container_id+logs), web_extractor_call
|
|
19
|
+
* (urls+goal+output; requires web_search alongside).
|
|
20
|
+
*
|
|
21
|
+
* C) native /api/v1/services/... — image + video generation. Video submits async
|
|
22
|
+
* and returns 200 even for a malformed request, so truth comes from polling.
|
|
23
|
+
*
|
|
24
|
+
* Config persists to <agentDir>/qtools.json. UI: /qtools-config
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { getAgentDir, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { Container, type SelectItem, SelectList, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
32
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
33
|
+
import { Type } from "typebox";
|
|
34
|
+
|
|
35
|
+
// ------------------------------------------------------------------ capability
|
|
36
|
+
|
|
37
|
+
const PROVIDER_PREFIX = "qwen-token-plan";
|
|
38
|
+
|
|
39
|
+
const BASE_URLS: Record<string, string> = {
|
|
40
|
+
"qwen-token-plan": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
|
|
41
|
+
"qwen-token-plan-individual": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
|
|
42
|
+
"qwen-token-plan-cn": "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The two surfaces have DIFFERENT support. Measured per model:
|
|
47
|
+
* chat = /chat/completions params (enable_search, agent strategy, ci)
|
|
48
|
+
* responses = /responses built-in tools (web_search, code_interpreter, web_extractor)
|
|
49
|
+
* Notably glm-5.2 rejects chat enable_search outright yet works on /responses,
|
|
50
|
+
* and qwen3.8-* lack chat agent/code_interpreter but work on /responses.
|
|
51
|
+
*/
|
|
52
|
+
interface Caps {
|
|
53
|
+
chat: { search: boolean; agent: boolean; ci: boolean };
|
|
54
|
+
responses: {
|
|
55
|
+
web_search: boolean;
|
|
56
|
+
code_interpreter: boolean;
|
|
57
|
+
web_extractor: boolean;
|
|
58
|
+
web_search_image: boolean;
|
|
59
|
+
image_search: boolean;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const chat = (search: boolean, agent: boolean, ci: boolean) => ({ search, agent, ci });
|
|
64
|
+
const c = (v: boolean) => v;
|
|
65
|
+
const res = (ws: boolean, ci: boolean, we: boolean, wsi: boolean, is: boolean) => ({
|
|
66
|
+
web_search: ws,
|
|
67
|
+
code_interpreter: ci,
|
|
68
|
+
web_extractor: we,
|
|
69
|
+
web_search_image: wsi,
|
|
70
|
+
image_search: is,
|
|
71
|
+
});
|
|
72
|
+
const ALL_RES = res(c(true), c(true), c(true), c(true), c(true));
|
|
73
|
+
/** Every text model does search/code/extract; only some do the image tools. */
|
|
74
|
+
const IMG = (wsi: boolean, is: boolean) => res(true, true, true, wsi, is);
|
|
75
|
+
const NO_RES = res(false, false, false, false, false);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Measured per model. The two image tools are gated separately from each other:
|
|
79
|
+
* `web_search_image` is qwen3.8-max only, while `image_search` also works on
|
|
80
|
+
* qwen3.8-flash / 3.7-plus / 3.6-flash. Untested cells are left false.
|
|
81
|
+
*/
|
|
82
|
+
const CAPS: Record<string, Caps> = {
|
|
83
|
+
"qwen3.8-max": { chat: chat(true, false, false), responses: ALL_RES },
|
|
84
|
+
"qwen3.8-flash": { chat: chat(true, false, false), responses: IMG(false, true) },
|
|
85
|
+
"qwen3.7-max": { chat: chat(true, true, true), responses: IMG(false, false) },
|
|
86
|
+
"qwen3.7-plus": { chat: chat(true, true, true), responses: IMG(false, true) },
|
|
87
|
+
"qwen3.6-flash": { chat: chat(true, true, true), responses: IMG(false, true) },
|
|
88
|
+
"deepseek-v4-pro": { chat: chat(true, true, true), responses: IMG(false, false) },
|
|
89
|
+
"deepseek-v4-pro-0813": { chat: chat(true, true, true), responses: IMG(false, false) },
|
|
90
|
+
"deepseek-v4-flash": { chat: chat(true, true, true), responses: IMG(false, false) },
|
|
91
|
+
"deepseek-v4-flash-0731": { chat: chat(true, true, true), responses: IMG(false, false) },
|
|
92
|
+
"glm-5.2": { chat: chat(false, false, false), responses: IMG(false, false) },
|
|
93
|
+
"wan2.7-image": { chat: chat(false, false, false), responses: NO_RES },
|
|
94
|
+
"wan2.7-image-pro": { chat: chat(false, false, false), responses: NO_RES },
|
|
95
|
+
"qwen-audio-3.0-tts-plus": { chat: chat(false, false, false), responses: NO_RES },
|
|
96
|
+
"qwen-audio-3.0-realtime-plus": { chat: chat(false, false, false), responses: NO_RES },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Which models actually run each /responses tool, for error messages. */
|
|
100
|
+
export function modelsSupporting(tool: keyof Caps["responses"]): string[] {
|
|
101
|
+
return Object.entries(CAPS)
|
|
102
|
+
.filter(([, k]) => k.responses[tool])
|
|
103
|
+
.map(([id]) => id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Unknown models: assume the common three, assume not the image tools. */
|
|
107
|
+
const UNKNOWN_CAPS: Caps = { chat: chat(true, false, false), responses: IMG(false, false) };
|
|
108
|
+
|
|
109
|
+
function capsFor(modelId: string): Caps {
|
|
110
|
+
return CAPS[modelId] ?? UNKNOWN_CAPS;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* glm-5.2 returns "This model does not support enable_search", which would break
|
|
115
|
+
* EVERY turn rather than just searches, so the main-loop injection is gated.
|
|
116
|
+
*/
|
|
117
|
+
function searchCapable(modelId: string): boolean {
|
|
118
|
+
return capsFor(modelId).chat.search;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --------------------------------------------------------------------- config
|
|
122
|
+
|
|
123
|
+
type SearchMode = "off" | "turbo" | "max";
|
|
124
|
+
|
|
125
|
+
interface QwenToolsConfig {
|
|
126
|
+
searchMode: SearchMode;
|
|
127
|
+
/** Model the /responses tools fall back to when the active one cannot. */
|
|
128
|
+
fallbackModel: string;
|
|
129
|
+
showReasoning: boolean;
|
|
130
|
+
/** Output cap for qwen_deep_search, which can otherwise burn ~340k tokens. */
|
|
131
|
+
deepSearchMaxTokens: number;
|
|
132
|
+
/** Defaults for the generation models (native endpoints, not chat models). */
|
|
133
|
+
imageModel: string;
|
|
134
|
+
videoModel: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Verified working on this plan via the native multimodal-generation endpoint. */
|
|
138
|
+
const IMAGE_MODELS = ["wan2.7-image", "wan2.7-image-pro", "qwen-image-3.0-pro"];
|
|
139
|
+
/** Verified working via async submit + task polling. i2v/r2v need extra input. */
|
|
140
|
+
const VIDEO_MODELS = ["happyhorse-1.1-t2v", "happyhorse-1.1-i2v", "happyhorse-1.1-r2v"];
|
|
141
|
+
const VIDEO_SIZES = ["1280*720", "1920*1080", "832*480"];
|
|
142
|
+
|
|
143
|
+
const DEFAULTS: QwenToolsConfig = {
|
|
144
|
+
searchMode: "turbo",
|
|
145
|
+
fallbackModel: "qwen3.7-max",
|
|
146
|
+
showReasoning: false,
|
|
147
|
+
deepSearchMaxTokens: 16384,
|
|
148
|
+
imageModel: "wan2.7-image",
|
|
149
|
+
videoModel: "happyhorse-1.1-t2v",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const MODE_NOTES: Record<SearchMode, string> = {
|
|
153
|
+
off: "Model answers from memory. Cheapest; no fresh facts.",
|
|
154
|
+
turbo: "~330 extra prompt tokens per turn. Searches, returns a fresh answer.",
|
|
155
|
+
max: "~420 extra prompt tokens per turn. Wider search plus inline [n] citations.",
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const SEARCH_MODES: SearchMode[] = ["off", "turbo", "max"];
|
|
159
|
+
const TOKEN_CAPS = [4096, 8192, 16384, 32768, 65536];
|
|
160
|
+
|
|
161
|
+
let config: QwenToolsConfig = { ...DEFAULTS };
|
|
162
|
+
|
|
163
|
+
const CONFIG_FILE = "qtools.json";
|
|
164
|
+
/** Pre-rename filename, migrated on first load. */
|
|
165
|
+
const LEGACY_CONFIG_FILE = "qwen-tools.json";
|
|
166
|
+
|
|
167
|
+
function configPath(): string {
|
|
168
|
+
return join(getAgentDir(), CONFIG_FILE);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function loadConfig(): void {
|
|
172
|
+
try {
|
|
173
|
+
const p = configPath();
|
|
174
|
+
let source = p;
|
|
175
|
+
if (!existsSync(p)) {
|
|
176
|
+
const legacy = join(getAgentDir(), LEGACY_CONFIG_FILE);
|
|
177
|
+
if (!existsSync(legacy)) return;
|
|
178
|
+
source = legacy;
|
|
179
|
+
}
|
|
180
|
+
const raw = JSON.parse(readFileSync(source, "utf8")) as Partial<QwenToolsConfig>;
|
|
181
|
+
config = {
|
|
182
|
+
...DEFAULTS,
|
|
183
|
+
...raw,
|
|
184
|
+
searchMode: SEARCH_MODES.includes(raw.searchMode as SearchMode)
|
|
185
|
+
? (raw.searchMode as SearchMode)
|
|
186
|
+
: DEFAULTS.searchMode,
|
|
187
|
+
deepSearchMaxTokens:
|
|
188
|
+
typeof raw.deepSearchMaxTokens === "number" && raw.deepSearchMaxTokens > 0
|
|
189
|
+
? raw.deepSearchMaxTokens
|
|
190
|
+
: DEFAULTS.deepSearchMaxTokens,
|
|
191
|
+
showReasoning: typeof raw.showReasoning === "boolean" ? raw.showReasoning : DEFAULTS.showReasoning,
|
|
192
|
+
fallbackModel: typeof raw.fallbackModel === "string" && raw.fallbackModel ? raw.fallbackModel : DEFAULTS.fallbackModel,
|
|
193
|
+
imageModel: IMAGE_MODELS.includes(raw.imageModel ?? "") ? raw.imageModel! : DEFAULTS.imageModel,
|
|
194
|
+
videoModel: VIDEO_MODELS.includes(raw.videoModel ?? "") ? raw.videoModel! : DEFAULTS.videoModel,
|
|
195
|
+
};
|
|
196
|
+
// Migrate the old filename once so the new one is authoritative.
|
|
197
|
+
if (source !== p) saveConfig();
|
|
198
|
+
} catch {
|
|
199
|
+
config = { ...DEFAULTS };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function saveConfig(): void {
|
|
204
|
+
try {
|
|
205
|
+
const dir = getAgentDir();
|
|
206
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
207
|
+
writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
208
|
+
} catch (err) {
|
|
209
|
+
lastSaveError = err instanceof Error ? err.message : String(err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let lastSaveError: string | undefined;
|
|
214
|
+
|
|
215
|
+
// -------------------------------------------------------------------- helpers
|
|
216
|
+
|
|
217
|
+
function isQwen(ctx: ExtensionContext): boolean {
|
|
218
|
+
return (ctx.model?.provider ?? "").startsWith(PROVIDER_PREFIX);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function resolveProvider(ctx: ExtensionContext): string {
|
|
222
|
+
const current = ctx.model?.provider ?? "";
|
|
223
|
+
if (current.startsWith(PROVIDER_PREFIX)) return current;
|
|
224
|
+
const ids = ctx.modelRegistry.getRegisteredProviderIds();
|
|
225
|
+
return ids.find((id) => id.startsWith(PROVIDER_PREFIX)) ?? "qwen-token-plan-individual";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function resolveEndpoint(ctx: ExtensionContext): Promise<{ baseUrl: string; apiKey: string }> {
|
|
229
|
+
const provider = resolveProvider(ctx);
|
|
230
|
+
const registered = ctx.modelRegistry.getProvider(provider);
|
|
231
|
+
const baseUrl =
|
|
232
|
+
(registered as { baseUrl?: string } | undefined)?.baseUrl ?? BASE_URLS[provider] ?? BASE_URLS["qwen-token-plan"];
|
|
233
|
+
const apiKey = (await ctx.modelRegistry.getApiKeyForProvider(provider)) ?? "";
|
|
234
|
+
if (!apiKey) throw new Error(`No API key resolved for ${provider}. Run /login.`);
|
|
235
|
+
return { baseUrl, apiKey };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Chat-completions agent-mode side call (deep search): needs chat.agent support. */
|
|
239
|
+
function pickChatCapable(ctx: ExtensionContext, requested?: string): string {
|
|
240
|
+
const active = ctx.model?.id ?? "";
|
|
241
|
+
for (const c of [requested, active, config.fallbackModel]) {
|
|
242
|
+
if (c && capsFor(c).chat.agent) return c;
|
|
243
|
+
}
|
|
244
|
+
return "qwen3.7-max";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* /responses tools: keep the ACTIVE model. Every chat model on this plan runs all
|
|
249
|
+
* three built-in tools there, so downgrading to the fallback would silently bill a
|
|
250
|
+
* different model than the one the user selected.
|
|
251
|
+
*/
|
|
252
|
+
function pickResponsesModel(ctx: ExtensionContext, tool: keyof Caps["responses"], requested?: string): string {
|
|
253
|
+
const active = ctx.model?.id ?? "";
|
|
254
|
+
for (const c of [requested, active, config.fallbackModel]) {
|
|
255
|
+
if (c && capsFor(c).responses[tool]) return c;
|
|
256
|
+
}
|
|
257
|
+
return "qwen3.7-max";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// --------------------------------------------------------------- /responses
|
|
261
|
+
|
|
262
|
+
type ResponsesContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string };
|
|
263
|
+
/**
|
|
264
|
+
* Bare content arrays are rejected for image input ("the role in last message must
|
|
265
|
+
* be in [user, function, tool]") — images must arrive wrapped in a user message.
|
|
266
|
+
*/
|
|
267
|
+
type ResponsesInput = string | ResponsesContent[] | Array<{ role: string; content: ResponsesContent[] }>;
|
|
268
|
+
|
|
269
|
+
interface ResponsesResult {
|
|
270
|
+
text: string;
|
|
271
|
+
reasoning: string;
|
|
272
|
+
traces: string[];
|
|
273
|
+
/** Image URLs returned by web_search_image / image_search. */
|
|
274
|
+
imageUrls: string[];
|
|
275
|
+
/** Which built-in tools actually fired; the model may decline to use them. */
|
|
276
|
+
callTypes: string[];
|
|
277
|
+
status: string;
|
|
278
|
+
usage?: { input_tokens?: number; output_tokens?: number };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** `arguments` and `output` arrive as JSON-encoded strings; be defensive. */
|
|
282
|
+
function parseImageCall(o: any): { text: string; urls: string[] } {
|
|
283
|
+
let args: unknown = o?.arguments ?? null;
|
|
284
|
+
if (typeof args === "string") {
|
|
285
|
+
try {
|
|
286
|
+
args = JSON.parse(args);
|
|
287
|
+
} catch {
|
|
288
|
+
/* keep raw */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
let out: unknown = o?.output ?? [];
|
|
292
|
+
if (typeof out === "string") {
|
|
293
|
+
try {
|
|
294
|
+
out = JSON.parse(out);
|
|
295
|
+
} catch {
|
|
296
|
+
out = [];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const rows = Array.isArray(out) ? out : [];
|
|
300
|
+
const urls = rows
|
|
301
|
+
.map((x: any) => (typeof x === "string" ? x : x?.url))
|
|
302
|
+
.filter((u: unknown): u is string => typeof u === "string" && u.startsWith("http"));
|
|
303
|
+
const listed = rows
|
|
304
|
+
.slice(0, 12)
|
|
305
|
+
.map((x: any, i: number) => ` ${i + 1}. ${x?.title ? String(x.title).slice(0, 100) + "\n " : ""}${x?.url ?? x}`)
|
|
306
|
+
.join("\n");
|
|
307
|
+
return {
|
|
308
|
+
text: `[${o?.type ?? "image_call"}] args: ${JSON.stringify(args)}\n${listed || " (no results parsed)"}`,
|
|
309
|
+
urls,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Accept an http(s)/data URL directly, or inline a local file as a data URL. */
|
|
314
|
+
/**
|
|
315
|
+
* Resolve an image reference to something the Qwen APIs accept: an http(s) URL or
|
|
316
|
+
* data: URL is passed through, a local path is inlined as a base64 data URL.
|
|
317
|
+
*
|
|
318
|
+
* Measured bound: a 5.0 MB PNG (6.7 MB of base64) submitted to happyhorse-1.1-i2v
|
|
319
|
+
* reached SUCCEEDED. The ceiling above that is unknown, so this limit is a guard
|
|
320
|
+
* rail with headroom rather than a verified maximum; no local transcoder is assumed
|
|
321
|
+
* available, so the message tells the user to host the file instead.
|
|
322
|
+
*/
|
|
323
|
+
const MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
324
|
+
|
|
325
|
+
function toImageUrl(image: string, maxBytes = MAX_INLINE_IMAGE_BYTES): string {
|
|
326
|
+
if (/^(https?:|data:)/i.test(image)) return image;
|
|
327
|
+
const path = image.replace(/^file:\/\//, "");
|
|
328
|
+
if (!existsSync(path)) {
|
|
329
|
+
throw new Error(`Image not found: ${image}. Pass an http(s) URL, a data: URL, or a local file path.`);
|
|
330
|
+
}
|
|
331
|
+
const buf = readFileSync(path);
|
|
332
|
+
if (buf.length > maxBytes) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`Local image is ${(buf.length / (1024 * 1024)).toFixed(1)} MB, above the ${(maxBytes / (1024 * 1024)).toFixed(0)} MB inline limit (verified to 5.0 MB). Upload it and pass the URL, or point -i at a smaller file.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
const ext = (path.split(".").pop() ?? "png").toLowerCase();
|
|
338
|
+
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext}`;
|
|
339
|
+
return `data:${mime};base64,${buf.toString("base64")}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function toImagePart(image: string): { type: "input_image"; image_url: string } {
|
|
343
|
+
return { type: "input_image", image_url: toImageUrl(image) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function responsesCall(
|
|
347
|
+
ctx: ExtensionContext,
|
|
348
|
+
options: { model: string; input: ResponsesInput; tools: string[]; maxTokens?: number; signal?: AbortSignal },
|
|
349
|
+
): Promise<ResponsesResult> {
|
|
350
|
+
const { baseUrl, apiKey } = await resolveEndpoint(ctx);
|
|
351
|
+
|
|
352
|
+
const res = await fetch(`${baseUrl}/responses`, {
|
|
353
|
+
method: "POST",
|
|
354
|
+
signal: options.signal,
|
|
355
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
356
|
+
body: JSON.stringify({
|
|
357
|
+
model: options.model,
|
|
358
|
+
input: options.input,
|
|
359
|
+
max_output_tokens: options.maxTokens ?? 4096,
|
|
360
|
+
tools: options.tools.map((type) => ({ type })),
|
|
361
|
+
}),
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const raw = await res.text();
|
|
365
|
+
let r: any;
|
|
366
|
+
try {
|
|
367
|
+
r = JSON.parse(raw);
|
|
368
|
+
} catch {
|
|
369
|
+
throw new Error(`Qwen /responses returned non-JSON (${res.status}): ${raw.slice(0, 400)}`);
|
|
370
|
+
}
|
|
371
|
+
if (!res.ok || r?.error) {
|
|
372
|
+
throw new Error(`Qwen /responses failed (${res.status}): ${r?.error?.message ?? String(raw).slice(0, 400)}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const traces: string[] = [];
|
|
376
|
+
const callTypes: string[] = [];
|
|
377
|
+
const imageUrls: string[] = [];
|
|
378
|
+
let text = "";
|
|
379
|
+
let reasoning = "";
|
|
380
|
+
|
|
381
|
+
for (const o of r?.output ?? []) {
|
|
382
|
+
switch (o?.type) {
|
|
383
|
+
case "message":
|
|
384
|
+
text += (o.content ?? []).map((c: any) => c?.text ?? "").join("");
|
|
385
|
+
break;
|
|
386
|
+
case "reasoning":
|
|
387
|
+
reasoning += (o.summary ?? []).map((s: any) => s?.text ?? "").join("");
|
|
388
|
+
break;
|
|
389
|
+
case "web_search_call": {
|
|
390
|
+
callTypes.push("web_search_call");
|
|
391
|
+
const a = o.action ?? {};
|
|
392
|
+
const sources = (a.sources ?? []).map((s: any) => s?.url).filter(Boolean);
|
|
393
|
+
traces.push(
|
|
394
|
+
`[web_search] query: ${a.query ?? "?"}` +
|
|
395
|
+
(sources.length ? `\n sources:\n${sources.map((s: string) => ` - ${s}`).join("\n")}` : ""),
|
|
396
|
+
);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
case "code_interpreter_call": {
|
|
400
|
+
callTypes.push("code_interpreter_call");
|
|
401
|
+
const logs = (o.outputs ?? []).map((x: any) => x?.logs ?? "").filter(Boolean).join("\n");
|
|
402
|
+
traces.push(`[code_interpreter] code:\n${o.code ?? "?"}\n container: ${o.container_id ?? "?"}\n output:\n${logs || "(none)"}`);
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
case "web_extractor_call":
|
|
406
|
+
callTypes.push("web_extractor_call");
|
|
407
|
+
traces.push(`[web_extractor] urls: ${(o.urls ?? []).join(", ")}\n goal: ${o.goal ?? "?"}\n output:\n${o.output ?? "(none)"}`);
|
|
408
|
+
break;
|
|
409
|
+
case "web_search_image_call":
|
|
410
|
+
case "image_search_call": {
|
|
411
|
+
callTypes.push(String(o.type));
|
|
412
|
+
const img = parseImageCall(o);
|
|
413
|
+
traces.push(img.text);
|
|
414
|
+
imageUrls.push(...img.urls);
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
default:
|
|
418
|
+
if (o?.type) {
|
|
419
|
+
if (String(o.type).endsWith("_call")) callTypes.push(String(o.type));
|
|
420
|
+
traces.push(`[${o.type}] ${JSON.stringify(o).slice(0, 400)}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
text: text.trim() || "(no message content)",
|
|
427
|
+
reasoning: reasoning.trim(),
|
|
428
|
+
traces,
|
|
429
|
+
imageUrls: [...new Set(imageUrls)],
|
|
430
|
+
callTypes,
|
|
431
|
+
status: r?.status ?? "?",
|
|
432
|
+
usage: r?.usage,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function formatResponses(t: ResponsesResult, expectCall?: string): string {
|
|
437
|
+
const parts: string[] = [];
|
|
438
|
+
if (t.traces.length) parts.push(`=== tool trace ===\n${t.traces.join("\n\n")}`);
|
|
439
|
+
if (config.showReasoning && t.reasoning) parts.push(`=== reasoning ===\n${t.reasoning}`);
|
|
440
|
+
parts.push(`=== result ===\n${t.text}`);
|
|
441
|
+
// Tool use is discretionary: the model can answer from memory and look confident.
|
|
442
|
+
// Say so loudly instead of returning an unlabelled guess.
|
|
443
|
+
if (expectCall && !t.callTypes.includes(expectCall)) {
|
|
444
|
+
parts.push(
|
|
445
|
+
`[WARNING: ${expectCall} never fired — this answer may be from model memory, NOT from a real search/execution. Retry with a more explicit ask or another model.]`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (t.imageUrls.length) {
|
|
449
|
+
parts.push(`=== image urls (${t.imageUrls.length}) ===\n${t.imageUrls.map((u, i) => ` ${i + 1}. ${u}`).join("\n")}`);
|
|
450
|
+
}
|
|
451
|
+
if (t.status === "incomplete") parts.push("[status: incomplete — raised max_output_tokens; narrow the ask or retry]");
|
|
452
|
+
if (t.usage) parts.push(`[tokens: in=${t.usage.input_tokens ?? "?"} out=${t.usage.output_tokens ?? "?"}]`);
|
|
453
|
+
return parts.join("\n\n");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ------------------------------------------------ /chat/completions agent mode
|
|
457
|
+
|
|
458
|
+
interface AgentResult {
|
|
459
|
+
text: string;
|
|
460
|
+
reasoning: string;
|
|
461
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Agent mode rejects any `tools` array, so this is a side call: streaming, with
|
|
466
|
+
* `enable_code_interpreter` or `search_strategy:"agent"` rather than pi's tools.
|
|
467
|
+
*/
|
|
468
|
+
async function agentModeCall(
|
|
469
|
+
ctx: ExtensionContext,
|
|
470
|
+
options: {
|
|
471
|
+
model: string;
|
|
472
|
+
prompt: string;
|
|
473
|
+
extra: Record<string, unknown>;
|
|
474
|
+
signal?: AbortSignal;
|
|
475
|
+
maxTokens?: number;
|
|
476
|
+
onUpdate?: (text: string) => void;
|
|
477
|
+
},
|
|
478
|
+
): Promise<AgentResult> {
|
|
479
|
+
const { baseUrl, apiKey } = await resolveEndpoint(ctx);
|
|
480
|
+
|
|
481
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
482
|
+
method: "POST",
|
|
483
|
+
signal: options.signal,
|
|
484
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
485
|
+
body: JSON.stringify({
|
|
486
|
+
model: options.model,
|
|
487
|
+
stream: true,
|
|
488
|
+
stream_options: { include_usage: true },
|
|
489
|
+
enable_thinking: true,
|
|
490
|
+
max_tokens: options.maxTokens ?? 8192,
|
|
491
|
+
messages: [{ role: "user", content: options.prompt }],
|
|
492
|
+
...options.extra,
|
|
493
|
+
}),
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
if (!res.ok || !res.body) {
|
|
497
|
+
const detail = (await res.text().catch(() => "")).slice(0, 500);
|
|
498
|
+
throw new Error(`Qwen agent-mode call failed (${res.status}): ${detail}`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const reader = res.body.getReader();
|
|
502
|
+
const decoder = new TextDecoder();
|
|
503
|
+
let buffer = "";
|
|
504
|
+
let content = "";
|
|
505
|
+
let reasoning = "";
|
|
506
|
+
let usage: AgentResult["usage"];
|
|
507
|
+
|
|
508
|
+
while (true) {
|
|
509
|
+
const { done, value } = await reader.read();
|
|
510
|
+
if (done) break;
|
|
511
|
+
buffer += decoder.decode(value, { stream: true });
|
|
512
|
+
|
|
513
|
+
let nl: number;
|
|
514
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
515
|
+
const line = buffer.slice(0, nl).trim();
|
|
516
|
+
buffer = buffer.slice(nl + 1);
|
|
517
|
+
if (!line.startsWith("data:")) continue;
|
|
518
|
+
const chunk = line.slice(5).trim();
|
|
519
|
+
if (!chunk || chunk === "[DONE]") continue;
|
|
520
|
+
|
|
521
|
+
let parsed: any;
|
|
522
|
+
try {
|
|
523
|
+
parsed = JSON.parse(chunk);
|
|
524
|
+
} catch {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (parsed?.error) throw new Error(parsed.error.message ?? JSON.stringify(parsed.error));
|
|
528
|
+
if (parsed?.usage) usage = parsed.usage;
|
|
529
|
+
for (const choice of parsed?.choices ?? []) {
|
|
530
|
+
const delta = choice?.delta ?? {};
|
|
531
|
+
if (typeof delta.content === "string") {
|
|
532
|
+
content += delta.content;
|
|
533
|
+
options.onUpdate?.(content);
|
|
534
|
+
}
|
|
535
|
+
if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return { text: content.trim() || "(empty response)", reasoning: reasoning.trim(), usage };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ------------------------------------------------------- native generation API
|
|
544
|
+
|
|
545
|
+
const MG_PATH = "/api/v1/services/aigc/multimodal-generation/generation";
|
|
546
|
+
const VIDEO_PATH = "/api/v1/services/aigc/video-generation/video-synthesis";
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Native endpoints hang off the host root, while the configured baseUrl ends in
|
|
550
|
+
* /compatible-mode/v1. Fall back to the known plan host if a provider ever hands
|
|
551
|
+
* us something unparseable rather than throwing out of every generation call.
|
|
552
|
+
*/
|
|
553
|
+
function originOf(baseUrl: string, provider: string): string {
|
|
554
|
+
try {
|
|
555
|
+
return new URL(baseUrl).origin;
|
|
556
|
+
} catch {
|
|
557
|
+
const fallback = BASE_URLS[provider];
|
|
558
|
+
if (fallback) {
|
|
559
|
+
try {
|
|
560
|
+
return new URL(fallback).origin;
|
|
561
|
+
} catch {
|
|
562
|
+
/* fall through */
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return "https://token-plan.ap-southeast-1.maas.aliyuncs.com";
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
interface NativeResponse {
|
|
570
|
+
status: number;
|
|
571
|
+
body: any;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function nativeCall(
|
|
575
|
+
ctx: ExtensionContext,
|
|
576
|
+
path: string,
|
|
577
|
+
body: unknown,
|
|
578
|
+
options?: { async?: boolean; signal?: AbortSignal; timeoutMs?: number },
|
|
579
|
+
): Promise<NativeResponse> {
|
|
580
|
+
const { baseUrl, apiKey } = await resolveEndpoint(ctx);
|
|
581
|
+
const origin = originOf(baseUrl, resolveProvider(ctx));
|
|
582
|
+
const res = await fetch(`${origin}${path}`, {
|
|
583
|
+
method: "POST",
|
|
584
|
+
signal: options?.signal,
|
|
585
|
+
headers: {
|
|
586
|
+
Authorization: `Bearer ${apiKey}`,
|
|
587
|
+
"Content-Type": "application/json",
|
|
588
|
+
...(options?.async ? { "X-DashScope-Async": "enable" } : {}),
|
|
589
|
+
},
|
|
590
|
+
body: JSON.stringify(body),
|
|
591
|
+
});
|
|
592
|
+
const text = await res.text();
|
|
593
|
+
let parsed: any = text;
|
|
594
|
+
try {
|
|
595
|
+
parsed = JSON.parse(text);
|
|
596
|
+
} catch {
|
|
597
|
+
/* keep text */
|
|
598
|
+
}
|
|
599
|
+
if (!res.ok) {
|
|
600
|
+
const msg = parsed?.message ?? parsed?.code ?? String(text).slice(0, 200);
|
|
601
|
+
throw new Error(`Qwen native ${path.split("/").pop()} failed (${res.status}): ${msg}`);
|
|
602
|
+
}
|
|
603
|
+
return { status: res.status, body: parsed };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function pollTask(
|
|
607
|
+
ctx: ExtensionContext,
|
|
608
|
+
taskId: string,
|
|
609
|
+
options?: { signal?: AbortSignal; onTick?: (status: string, elapsedS: number) => void; timeoutMs?: number },
|
|
610
|
+
): Promise<any> {
|
|
611
|
+
const { baseUrl, apiKey } = await resolveEndpoint(ctx);
|
|
612
|
+
const origin = originOf(baseUrl, resolveProvider(ctx));
|
|
613
|
+
const deadline = Date.now() + (options?.timeoutMs ?? 10 * 60 * 1000);
|
|
614
|
+
let attempt = 0;
|
|
615
|
+
while (Date.now() < deadline) {
|
|
616
|
+
await new Promise((r) => setTimeout(r, Math.min(3000 + attempt * 1500, 15000)));
|
|
617
|
+
attempt++;
|
|
618
|
+
const res = await fetch(`${origin}/api/v1/tasks/${taskId}`, {
|
|
619
|
+
signal: options?.signal,
|
|
620
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
621
|
+
});
|
|
622
|
+
const body = await res.json().catch(() => ({}));
|
|
623
|
+
const status = body?.output?.task_status ?? "UNKNOWN";
|
|
624
|
+
options?.onTick?.(status, Math.round((Date.now() - (deadline - (options?.timeoutMs ?? 600000))) / 1000));
|
|
625
|
+
if (status === "SUCCEEDED" || status === "FAILED" || status === "UNKNOWN") return body?.output ?? {};
|
|
626
|
+
}
|
|
627
|
+
throw new Error(`Task ${taskId} did not finish within the timeout; poll /api/v1/tasks/${taskId} later.`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Extract every image URL from a multimodal-generation response. */
|
|
631
|
+
function extractImageUrls(body: any): string[] {
|
|
632
|
+
const urls: string[] = [];
|
|
633
|
+
for (const choice of body?.output?.choices ?? []) {
|
|
634
|
+
for (const part of choice?.message?.content ?? []) {
|
|
635
|
+
if (typeof part?.image === "string") urls.push(part.image);
|
|
636
|
+
if (typeof part?.image_url?.url === "string") urls.push(part.image_url.url);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (typeof body?.output?.image_url === "string") urls.push(body.output.image_url);
|
|
640
|
+
return [...new Set(urls)];
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Shared core so the tool and the /qimage command behave identically. */
|
|
644
|
+
async function generateImage(
|
|
645
|
+
ctx: ExtensionContext,
|
|
646
|
+
opts: { prompt: string; model?: string; negativePrompt?: string; size?: string; signal?: AbortSignal },
|
|
647
|
+
): Promise<{ model: string; urls: string[]; raw: unknown }> {
|
|
648
|
+
const model = opts.model && IMAGE_MODELS.includes(opts.model) ? opts.model : config.imageModel;
|
|
649
|
+
const parameters: Record<string, unknown> = { result_format: "message" };
|
|
650
|
+
if (opts.size) parameters.size = opts.size;
|
|
651
|
+
if (opts.negativePrompt) parameters.negative_prompt = opts.negativePrompt;
|
|
652
|
+
const r = await nativeCall(ctx, MG_PATH, {
|
|
653
|
+
model,
|
|
654
|
+
input: { messages: [{ role: "user", content: [{ text: opts.prompt }] }] },
|
|
655
|
+
parameters,
|
|
656
|
+
}, { signal: opts.signal });
|
|
657
|
+
return { model, urls: extractImageUrls(r.body), raw: r.body };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Which `input.media[].type` each video model requires. Measured by reading the
|
|
662
|
+
* validation errors: i2v wants exactly "first_frame", r2v exactly "reference_image",
|
|
663
|
+
* and t2v takes no image at all. Anything else fails with
|
|
664
|
+
* "Input should be 'first_frame': input.media.0.type".
|
|
665
|
+
*/
|
|
666
|
+
const VIDEO_MEDIA_TYPE: Record<string, string | undefined> = {
|
|
667
|
+
"happyhorse-1.1-t2v": undefined,
|
|
668
|
+
"happyhorse-1.1-i2v": "first_frame",
|
|
669
|
+
"happyhorse-1.1-r2v": "reference_image",
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
interface VideoResult {
|
|
673
|
+
model: string;
|
|
674
|
+
taskId: string;
|
|
675
|
+
status: string;
|
|
676
|
+
url?: string;
|
|
677
|
+
/** Task-level failure text. Submits always return 200, so this is the only place a bad request shows up. */
|
|
678
|
+
detail?: string;
|
|
679
|
+
usedImage?: string;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Shared core for /qvideo and qwen_generate_video. Blocks on task polling. */
|
|
683
|
+
async function generateVideo(
|
|
684
|
+
ctx: ExtensionContext,
|
|
685
|
+
opts: {
|
|
686
|
+
prompt: string;
|
|
687
|
+
model?: string;
|
|
688
|
+
size?: string;
|
|
689
|
+
image?: string;
|
|
690
|
+
signal?: AbortSignal;
|
|
691
|
+
onTick?: (s: string, sec: number) => void;
|
|
692
|
+
},
|
|
693
|
+
): Promise<VideoResult> {
|
|
694
|
+
const hasImage = Boolean(opts.image?.trim());
|
|
695
|
+
let model = opts.model && VIDEO_MODELS.includes(opts.model) ? opts.model : config.videoModel;
|
|
696
|
+
// Passing an image to the text-only model is intent, not a mistake: switch to i2v.
|
|
697
|
+
if (hasImage && !VIDEO_MEDIA_TYPE[model]) model = "happyhorse-1.1-i2v";
|
|
698
|
+
const mediaType = VIDEO_MEDIA_TYPE[model];
|
|
699
|
+
if (!hasImage && mediaType) {
|
|
700
|
+
throw new Error(
|
|
701
|
+
`${model} requires an image (tool parameter "image", or /qvideo -i <path|url>). Use happyhorse-1.1-t2v for text-only video.`,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const input: Record<string, unknown> = { prompt: opts.prompt };
|
|
706
|
+
let usedImage: string | undefined;
|
|
707
|
+
if (hasImage && mediaType) {
|
|
708
|
+
const url = toImageUrl(opts.image!.trim());
|
|
709
|
+
input.media = [{ type: mediaType, url }];
|
|
710
|
+
usedImage = url.startsWith("data:") ? `inline image (${Math.round(url.length / 1024)} KB base64)` : url;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const submit = await nativeCall(
|
|
714
|
+
ctx,
|
|
715
|
+
VIDEO_PATH,
|
|
716
|
+
{
|
|
717
|
+
model,
|
|
718
|
+
input,
|
|
719
|
+
parameters: { size: opts.size && VIDEO_SIZES.includes(opts.size) ? opts.size : VIDEO_SIZES[0] },
|
|
720
|
+
},
|
|
721
|
+
{ async: true, signal: opts.signal },
|
|
722
|
+
);
|
|
723
|
+
const taskId = submit.body?.output?.task_id;
|
|
724
|
+
if (!taskId) throw new Error(`No task_id returned: ${JSON.stringify(submit.body).slice(0, 300)}`);
|
|
725
|
+
const out = await pollTask(ctx, taskId, { signal: opts.signal, onTick: opts.onTick });
|
|
726
|
+
return {
|
|
727
|
+
model,
|
|
728
|
+
taskId,
|
|
729
|
+
status: out?.task_status ?? "UNKNOWN",
|
|
730
|
+
url: out?.video_url,
|
|
731
|
+
detail: out?.message ?? out?.code,
|
|
732
|
+
usedImage,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Download a hosted media URL into <cwd>/out/qtools/. The commands do this because
|
|
738
|
+
* these signed OSS URLs are long enough to wrap across terminal rows, so copying one
|
|
739
|
+
* out of the notify panel reliably yields a truncated (failing) link.
|
|
740
|
+
*/
|
|
741
|
+
async function saveMedia(ctx: ExtensionContext, url: string, model: string, signal?: AbortSignal): Promise<string> {
|
|
742
|
+
const dir = join(ctx.cwd, "out", "qtools");
|
|
743
|
+
mkdirSync(dir, { recursive: true });
|
|
744
|
+
const ext = (url.split("?")[0].match(/\.(png|jpe?g|webp|mp4|wav|mp3)$/i) ?? [])[0]?.toLowerCase() || ".bin";
|
|
745
|
+
const stamp = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
|
|
746
|
+
const dest = join(dir, `${model}-${stamp}${ext}`);
|
|
747
|
+
const res = await fetch(url, { signal });
|
|
748
|
+
if (!res.ok) throw new Error(`download failed (${res.status}) for ${url.slice(0, 80)}`);
|
|
749
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
750
|
+
writeFileSync(dest, buf);
|
|
751
|
+
return `${dest} (${(buf.length / 1024).toFixed(0)} KB)`;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Full parameter help for the generation commands. pi exposes no argumentHint for
|
|
756
|
+
* extension commands, so discoverability comes from three places: the signature in
|
|
757
|
+
* the command description (shown in the `/` picker), `--help`/no-args printing this
|
|
758
|
+
* block, and argument completions.
|
|
759
|
+
*/
|
|
760
|
+
function genUsage(kind: "image" | "video", cwd: string): string {
|
|
761
|
+
const outDir = join(cwd, "out", "qtools");
|
|
762
|
+
if (kind === "image") {
|
|
763
|
+
return [
|
|
764
|
+
"/qimage <prompt> [-m model] [-s WxH]",
|
|
765
|
+
"",
|
|
766
|
+
" prompt what to draw. No quotes needed: flags are stripped, the rest is the prompt.",
|
|
767
|
+
` -m model: ${IMAGE_MODELS.join(", ")} (default: ${config.imageModel})`,
|
|
768
|
+
" -s e.g. 1024x1024. Must be 589824-16777216 total pixels (768x768 .. 4096x4096).",
|
|
769
|
+
" --help this text",
|
|
770
|
+
"",
|
|
771
|
+
`Writes one image to ${outDir}/<model>-<timestamp>.png`,
|
|
772
|
+
"Agent equivalent: qwen_generate_image (adds negativePrompt and save).",
|
|
773
|
+
].join("\n");
|
|
774
|
+
}
|
|
775
|
+
return [
|
|
776
|
+
"/qvideo <prompt> [-i <path|url>] [-m model] [-s WxH]",
|
|
777
|
+
"",
|
|
778
|
+
" prompt scene and motion.",
|
|
779
|
+
" -i source image to animate (min 300x300). Local paths are inlined as data URLs.",
|
|
780
|
+
` -m model: ${VIDEO_MODELS.join(", ")} (default: ${config.videoModel})`,
|
|
781
|
+
" t2v = text only, i2v = -i as first frame, r2v = -i as reference.",
|
|
782
|
+
" Passing -i while -m is t2v switches to i2v automatically.",
|
|
783
|
+
` -s ${VIDEO_SIZES.join(", ")}. Advisory for i2v/r2v: output follows the source aspect ratio.`,
|
|
784
|
+
" --help this text",
|
|
785
|
+
"",
|
|
786
|
+
`Async task, ~90-120s. Writes to ${outDir}/<model>-<timestamp>.mp4`,
|
|
787
|
+
"Agent equivalent: qwen_generate_video (adds save).",
|
|
788
|
+
].join("\n");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const FLAG_MODEL = ["-m", "--model"];
|
|
792
|
+
const FLAG_SIZE = ["-s", "--size"];
|
|
793
|
+
const FLAG_IMAGE = ["-i", "--image", "--img"];
|
|
794
|
+
|
|
795
|
+
function flagKind(tok: string | undefined): "model" | "size" | "image" | undefined {
|
|
796
|
+
if (!tok) return undefined;
|
|
797
|
+
if (FLAG_MODEL.includes(tok)) return "model";
|
|
798
|
+
if (FLAG_SIZE.includes(tok)) return "size";
|
|
799
|
+
if (FLAG_IMAGE.includes(tok)) return "image";
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function wantsHelp(args: string): boolean {
|
|
804
|
+
const wrapped = ` ${args.trim()} `;
|
|
805
|
+
return wrapped.includes(" -h ") || wrapped.includes(" --help ") || args.trim() === "help";
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Completions for /qimage and /qvideo. pi hands over the whole argument text and
|
|
810
|
+
* replaces that entire region with the chosen value, so every candidate is built as
|
|
811
|
+
* "<already typed> <completion>". Returns null while the user is typing a prompt,
|
|
812
|
+
* otherwise picking a suggestion would silently delete what they wrote.
|
|
813
|
+
*/
|
|
814
|
+
function genCompletions(prefix: string, opts: { models: string[]; sizes: string[]; image: boolean }): AutocompleteItem[] | null {
|
|
815
|
+
const trimmed = prefix.replace(/\s+$/, "");
|
|
816
|
+
const toks = trimmed.length ? trimmed.split(/\s+/) : [];
|
|
817
|
+
const last = toks[toks.length - 1];
|
|
818
|
+
const lastIsFlag = flagKind(last);
|
|
819
|
+
const kind = lastIsFlag ?? flagKind(toks[toks.length - 2]);
|
|
820
|
+
const head = lastIsFlag ? toks.slice(0, -1) : flagKind(toks[toks.length - 2]) ? toks.slice(0, -2) : null;
|
|
821
|
+
|
|
822
|
+
if (kind && head) {
|
|
823
|
+
if (kind === "image") return null; // filesystem paths: nothing safe to suggest
|
|
824
|
+
const partial = lastIsFlag ? "" : (last ?? "");
|
|
825
|
+
const pool = kind === "model" ? opts.models : opts.sizes;
|
|
826
|
+
const items = pool
|
|
827
|
+
.filter((v) => v.startsWith(partial))
|
|
828
|
+
.map((v) => ({
|
|
829
|
+
value: [...head, v].join(" ") + " ",
|
|
830
|
+
label: v,
|
|
831
|
+
description: kind === "model" ? "model" : "resolution",
|
|
832
|
+
}));
|
|
833
|
+
return items.length ? items : null;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (toks.length === 0 || (last && last.startsWith("-"))) {
|
|
837
|
+
const flagPrefix = last && last.startsWith("-") ? last : "";
|
|
838
|
+
const keep = last && last.startsWith("-") ? toks.slice(0, -1) : toks;
|
|
839
|
+
const cands: AutocompleteItem[] = [];
|
|
840
|
+
if (opts.image) cands.push({ value: "-i ", label: "-i", description: "source image path/url -> image-to-video" });
|
|
841
|
+
cands.push(
|
|
842
|
+
{ value: "-m ", label: "-m", description: "model" },
|
|
843
|
+
{ value: "-s ", label: "-s", description: "size" },
|
|
844
|
+
{ value: "--help", label: "--help", description: "show all parameters" },
|
|
845
|
+
);
|
|
846
|
+
const items = cands
|
|
847
|
+
.filter((c) => c.label.startsWith(flagPrefix))
|
|
848
|
+
.map((c) => ({ ...c, value: `${keep.length ? keep.join(" ") + " " : ""}${c.value}`.replace(/\s+/g, " ") }));
|
|
849
|
+
return items.length ? items : null;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function parseGenArgs(
|
|
856
|
+
args: string,
|
|
857
|
+
models: string[],
|
|
858
|
+
): { prompt: string; model?: string; size?: string; image?: string } {
|
|
859
|
+
const tokens = args.trim().split(/\s+/);
|
|
860
|
+
const parts: string[] = [];
|
|
861
|
+
let model: string | undefined;
|
|
862
|
+
let size: string | undefined;
|
|
863
|
+
let image: string | undefined;
|
|
864
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
865
|
+
const t = tokens[i];
|
|
866
|
+
if ((t === "-m" || t === "--model") && tokens[i + 1]) {
|
|
867
|
+
const m = tokens[++i];
|
|
868
|
+
model = models.includes(m) ? m : undefined;
|
|
869
|
+
if (!model) parts.push(m); // not a known model: treat as prompt text
|
|
870
|
+
} else if ((t === "-s" || t === "--size") && tokens[i + 1]) {
|
|
871
|
+
size = tokens[++i].replace(/[x×]/g, "*");
|
|
872
|
+
} else if ((t === "-i" || t === "--image" || t === "--img") && tokens[i + 1]) {
|
|
873
|
+
image = tokens[++i];
|
|
874
|
+
} else {
|
|
875
|
+
parts.push(t);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return { prompt: parts.join(" ").trim(), model, size, image };
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function formatUrls(label: string, model: string, urls: string[]): string {
|
|
882
|
+
if (!urls.length) return `${label}: no URL in the response from ${model}.`;
|
|
883
|
+
return `${label} from ${model} (${urls.length}):\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join("\n")}\n\nHosted URLs expire; download if you need to keep them.`;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// -------------------------------------------------------------- settings TUI
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* `theme` is only a *type* export from pi-coding-agent, not a runtime value, so the
|
|
890
|
+
* theme object has to come from the ctx.ui.custom callback argument.
|
|
891
|
+
*/
|
|
892
|
+
interface ThemeLike {
|
|
893
|
+
fg: (color: string, text: string) => string;
|
|
894
|
+
bold: (text: string) => string;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function makeSubmenu(
|
|
898
|
+
th: ThemeLike,
|
|
899
|
+
title: string,
|
|
900
|
+
items: SelectItem[],
|
|
901
|
+
current: string,
|
|
902
|
+
done: (value?: string) => void,
|
|
903
|
+
): { render: (w: number) => string[]; invalidate: () => void; handleInput: (d: string) => void } {
|
|
904
|
+
const container = new Container();
|
|
905
|
+
container.addChild(new Text(`${th.fg("accent", th.bold(title))}`, 0, 0));
|
|
906
|
+
container.addChild(new Spacer(1));
|
|
907
|
+
|
|
908
|
+
const list = new SelectList(items, Math.min(items.length + 2, 12), getSelectListTheme());
|
|
909
|
+
const idx = items.findIndex((i) => i.value === current);
|
|
910
|
+
if (idx >= 0) list.setSelectedIndex(idx);
|
|
911
|
+
list.onSelect = (item) => done(item.value);
|
|
912
|
+
list.onCancel = () => done(undefined);
|
|
913
|
+
container.addChild(list);
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
render: (width: number) => container.render(width),
|
|
917
|
+
invalidate: () => container.invalidate(),
|
|
918
|
+
handleInput: (data: string) => list.handleInput(data),
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function capabilitySummary(caps: Caps): string {
|
|
923
|
+
const y = (b: boolean) => (b ? "yes" : "NO");
|
|
924
|
+
return (
|
|
925
|
+
`chat: search ${y(caps.chat.search)}, agent ${y(caps.chat.agent)}, code ${y(caps.chat.ci)}` +
|
|
926
|
+
` | /responses: search ${y(caps.responses.web_search)}, code ${y(caps.responses.code_interpreter)},` +
|
|
927
|
+
` extract ${y(caps.responses.web_extractor)},` +
|
|
928
|
+
` t2i-img ${y(caps.responses.web_search_image)}, i2i-img ${y(caps.responses.image_search)}`
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Console capability label -> the `tools[].type` string the API actually wants.
|
|
934
|
+
* This is the trap that made me mis-report both image tools as dead: the Qwen UI
|
|
935
|
+
* says t2i_search / i2i_search, but sending those as a type is silently accepted
|
|
936
|
+
* and ignored, exactly like a made-up name. Real names are below.
|
|
937
|
+
*/
|
|
938
|
+
const TYPE_MAP: Array<{ label: string; type: string; tool: string }> = [
|
|
939
|
+
{ label: "web_search", type: "web_search", tool: "qwen_web_search" },
|
|
940
|
+
{ label: "web_extractor", type: "web_extractor", tool: "qwen_web_extractor (needs web_search)" },
|
|
941
|
+
{ label: "code_interpreter", type: "code_interpreter", tool: "qwen_code_interpreter" },
|
|
942
|
+
{ label: "t2i_search", type: "web_search_image", tool: "qwen_image_search" },
|
|
943
|
+
{ label: "i2i_search", type: "image_search", tool: "qwen_reverse_image_search" },
|
|
944
|
+
];
|
|
945
|
+
|
|
946
|
+
function capabilityItems(): SelectItem[] {
|
|
947
|
+
return Object.entries(CAPS).map(([id, caps]) => ({
|
|
948
|
+
value: id,
|
|
949
|
+
label: id,
|
|
950
|
+
description: capabilitySummary(caps),
|
|
951
|
+
}));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function typeMapItems(): SelectItem[] {
|
|
955
|
+
return TYPE_MAP.map((t) => ({
|
|
956
|
+
value: t.type,
|
|
957
|
+
label: `${t.label} -> "${t.type}"`,
|
|
958
|
+
description: `${t.tool} | works on: ${modelsSupporting(t.type as keyof Caps["responses"]).join(", ") || "none"}`,
|
|
959
|
+
}));
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/** Descriptions are recomputed after every change so they never go stale. */
|
|
963
|
+
function descriptionFor(id: string, ctx: ExtensionContext): string {
|
|
964
|
+
const model = ctx.model?.id ?? "?";
|
|
965
|
+
const caps = capsFor(model);
|
|
966
|
+
switch (id) {
|
|
967
|
+
case "searchMode":
|
|
968
|
+
return (
|
|
969
|
+
MODE_NOTES[config.searchMode] +
|
|
970
|
+
(caps.chat.search ? "" : ` — WARNING: ${model} rejects enable_search, so injection is skipped`)
|
|
971
|
+
);
|
|
972
|
+
case "fallbackModel":
|
|
973
|
+
return `Model the qwen_* tools run on only when the active model cannot. Currently ${config.fallbackModel}. (/responses tools use the active model — every chat model here supports them.)`;
|
|
974
|
+
case "showReasoning":
|
|
975
|
+
return "Adds the model's own plan/rationale to tool results. More context, more tokens.";
|
|
976
|
+
case "deepSearchMaxTokens":
|
|
977
|
+
return `max_tokens cap for qwen_deep_search. It burned ~340k prompt tokens uncapped; limit is ${config.deepSearchMaxTokens} now.`;
|
|
978
|
+
case "capabilities":
|
|
979
|
+
return "Measured against the live endpoint. Note the two image tools are gated differently.";
|
|
980
|
+
case "imageModel":
|
|
981
|
+
return `Default for qwen_generate_image and /qimage. All three verified working via the native multimodal-generation endpoint.`;
|
|
982
|
+
case "videoModel":
|
|
983
|
+
return `Default for qwen_generate_video and /qvideo. t2v animates text; i2v animates a starting frame (media type first_frame); r2v takes a reference image (reference_image). All video is async and takes ~90-120s.`;
|
|
984
|
+
case "typemap":
|
|
985
|
+
return "Console capability labels differ from the API tools[].type strings. Sending a label is silently ignored.";
|
|
986
|
+
case "provider":
|
|
987
|
+
return "Where the API key and base URL come from, plus whether the active model is a Qwen plan model.";
|
|
988
|
+
}
|
|
989
|
+
return "";
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
|
|
993
|
+
const model = ctx.model?.id ?? "?";
|
|
994
|
+
const caps = capsFor(model);
|
|
995
|
+
const y = (b: boolean) => (b ? "y" : "-");
|
|
996
|
+
const capLine =
|
|
997
|
+
`${model}: chat ${y(caps.chat.search)}${y(caps.chat.agent)}${y(caps.chat.ci)}` +
|
|
998
|
+
` | res ${y(caps.responses.web_search)}${y(caps.responses.code_interpreter)}${y(caps.responses.web_extractor)}` +
|
|
999
|
+
`${y(caps.responses.web_search_image)}${y(caps.responses.image_search)}`;
|
|
1000
|
+
return [
|
|
1001
|
+
{
|
|
1002
|
+
id: "searchMode",
|
|
1003
|
+
label: "Main-loop web search",
|
|
1004
|
+
description: descriptionFor("searchMode", ctx),
|
|
1005
|
+
currentValue: config.searchMode,
|
|
1006
|
+
values: [...SEARCH_MODES],
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
id: "fallbackModel",
|
|
1010
|
+
label: "Built-in tool model",
|
|
1011
|
+
description: descriptionFor("fallbackModel", ctx),
|
|
1012
|
+
currentValue: config.fallbackModel,
|
|
1013
|
+
// NOTE: do NOT pass { navigateTo } to done(). Verified against pi-tui 0.87.1:
|
|
1014
|
+
// closeSubmenu() leaves submenuComponent set, so the submenu never closes
|
|
1015
|
+
// (onChange still fires, so it looks like a render hang). The cursor already
|
|
1016
|
+
// returns to this row, so navigateTo buys nothing here.
|
|
1017
|
+
submenu: (current, done) => makeSubmenu(th, "Built-in tool model", capabilityItems(), current, (v) => done(v)),
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
id: "showReasoning",
|
|
1021
|
+
label: "Include model reasoning",
|
|
1022
|
+
description: descriptionFor("showReasoning", ctx),
|
|
1023
|
+
currentValue: config.showReasoning ? "enabled" : "disabled",
|
|
1024
|
+
values: ["disabled", "enabled"],
|
|
1025
|
+
},
|
|
1026
|
+
{
|
|
1027
|
+
id: "deepSearchMaxTokens",
|
|
1028
|
+
label: "Deep search output cap",
|
|
1029
|
+
description: descriptionFor("deepSearchMaxTokens", ctx),
|
|
1030
|
+
currentValue: String(config.deepSearchMaxTokens),
|
|
1031
|
+
submenu: (current, done) =>
|
|
1032
|
+
makeSubmenu(
|
|
1033
|
+
th,
|
|
1034
|
+
"Deep search output cap",
|
|
1035
|
+
TOKEN_CAPS.map((n) => ({ value: String(n), label: String(n) })),
|
|
1036
|
+
current,
|
|
1037
|
+
(v) => done(v),
|
|
1038
|
+
),
|
|
1039
|
+
},
|
|
1040
|
+
{
|
|
1041
|
+
id: "imageModel",
|
|
1042
|
+
label: "Default image model",
|
|
1043
|
+
description: descriptionFor("imageModel", ctx),
|
|
1044
|
+
currentValue: config.imageModel,
|
|
1045
|
+
submenu: (current, done) =>
|
|
1046
|
+
makeSubmenu(th, "Default image model", IMAGE_MODELS.map((m) => ({ value: m, label: m })), current, (v) => done(v)),
|
|
1047
|
+
},
|
|
1048
|
+
{
|
|
1049
|
+
id: "videoModel",
|
|
1050
|
+
label: "Default video model",
|
|
1051
|
+
description: descriptionFor("videoModel", ctx),
|
|
1052
|
+
currentValue: config.videoModel,
|
|
1053
|
+
submenu: (current, done) =>
|
|
1054
|
+
makeSubmenu(
|
|
1055
|
+
th,
|
|
1056
|
+
"Default video model",
|
|
1057
|
+
VIDEO_MODELS.map((m) => ({
|
|
1058
|
+
value: m,
|
|
1059
|
+
label: m,
|
|
1060
|
+
description: VIDEO_MEDIA_TYPE[m]
|
|
1061
|
+
? `needs -i / "image"; media type "${VIDEO_MEDIA_TYPE[m]}" (min 300x300)`
|
|
1062
|
+
: "text -> video, no image needed",
|
|
1063
|
+
})),
|
|
1064
|
+
current,
|
|
1065
|
+
(v) => done(v),
|
|
1066
|
+
),
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
id: "typemap",
|
|
1070
|
+
label: "Tool names (label vs API)",
|
|
1071
|
+
description: descriptionFor("typemap", ctx),
|
|
1072
|
+
currentValue: `${TYPE_MAP.length} mapped`,
|
|
1073
|
+
submenu: (_current, done) => makeSubmenu(th, "Console label -> API tools[].type", typeMapItems(), "", () => done(undefined)),
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
id: "capabilities",
|
|
1077
|
+
label: "Per-model capabilities",
|
|
1078
|
+
description: descriptionFor("capabilities", ctx),
|
|
1079
|
+
currentValue: capLine,
|
|
1080
|
+
submenu: (_current, done) => makeSubmenu(th, "Per-model capabilities", capabilityItems(), "", () => done(undefined)),
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
id: "provider",
|
|
1084
|
+
label: "Provider",
|
|
1085
|
+
description: descriptionFor("provider", ctx),
|
|
1086
|
+
currentValue: `${resolveProvider(ctx)}${isQwen(ctx) ? " (active)" : " (not active)"}`,
|
|
1087
|
+
},
|
|
1088
|
+
];
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function applyChange(id: string, value: string): void {
|
|
1092
|
+
switch (id) {
|
|
1093
|
+
case "searchMode":
|
|
1094
|
+
if (SEARCH_MODES.includes(value as SearchMode)) config.searchMode = value as SearchMode;
|
|
1095
|
+
break;
|
|
1096
|
+
case "fallbackModel":
|
|
1097
|
+
if (value) config.fallbackModel = value;
|
|
1098
|
+
break;
|
|
1099
|
+
case "showReasoning":
|
|
1100
|
+
config.showReasoning = value === "enabled";
|
|
1101
|
+
break;
|
|
1102
|
+
case "imageModel":
|
|
1103
|
+
if (IMAGE_MODELS.includes(value)) config.imageModel = value;
|
|
1104
|
+
break;
|
|
1105
|
+
case "videoModel":
|
|
1106
|
+
if (VIDEO_MODELS.includes(value)) config.videoModel = value;
|
|
1107
|
+
break;
|
|
1108
|
+
case "deepSearchMaxTokens": {
|
|
1109
|
+
const n = Number.parseInt(value, 10);
|
|
1110
|
+
if (Number.isFinite(n) && n > 0) config.deepSearchMaxTokens = n;
|
|
1111
|
+
break;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
saveConfig();
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// ------------------------------------------------------------------ extension
|
|
1118
|
+
|
|
1119
|
+
loadConfig();
|
|
1120
|
+
|
|
1121
|
+
export default function (pi: ExtensionAPI): void {
|
|
1122
|
+
// ---- main-loop web_search injection (chat/completions) ----
|
|
1123
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
1124
|
+
if (config.searchMode === "off" || !isQwen(ctx)) return undefined;
|
|
1125
|
+
|
|
1126
|
+
const payload = event.payload;
|
|
1127
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
1128
|
+
const body = payload as Record<string, unknown>;
|
|
1129
|
+
if (typeof body.model !== "string") return undefined;
|
|
1130
|
+
if (!searchCapable(body.model)) return undefined;
|
|
1131
|
+
|
|
1132
|
+
return {
|
|
1133
|
+
...body,
|
|
1134
|
+
enable_search: true,
|
|
1135
|
+
search_options: {
|
|
1136
|
+
// Never "agent": that is Agent mode and rejects pi's tools.
|
|
1137
|
+
search_strategy: config.searchMode,
|
|
1138
|
+
enable_source: true,
|
|
1139
|
+
enable_citation: config.searchMode === "max",
|
|
1140
|
+
},
|
|
1141
|
+
};
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
// ------------------------------------------------------------------- tools
|
|
1145
|
+
pi.registerTool({
|
|
1146
|
+
name: "qwen_web_search",
|
|
1147
|
+
label: "Qwen Web Search",
|
|
1148
|
+
description:
|
|
1149
|
+
"Server-side web search via Qwen's Responses API. Returns the answer plus the actual queries " +
|
|
1150
|
+
"issued and the source URLs consulted. Use when you need current facts with citations.",
|
|
1151
|
+
promptSnippet: "Search the web and get back answers with source URLs",
|
|
1152
|
+
promptGuidelines: [
|
|
1153
|
+
"Use qwen_web_search when you need current or verifiable facts with source URLs, and want the search performed server-side rather than via curl.",
|
|
1154
|
+
],
|
|
1155
|
+
parameters: Type.Object({
|
|
1156
|
+
query: Type.String({ description: "The search question or query." }),
|
|
1157
|
+
model: Type.Optional(Type.String({ description: "Override model id." })),
|
|
1158
|
+
}),
|
|
1159
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1160
|
+
onUpdate?.({ content: [{ type: "text", text: `searching: ${params.query}` }] });
|
|
1161
|
+
const model = pickResponsesModel(ctx, "web_search", params.model);
|
|
1162
|
+
const t = await responsesCall(ctx, { model, input: params.query, tools: ["web_search"], signal });
|
|
1163
|
+
return {
|
|
1164
|
+
content: [{ type: "text", text: formatResponses(t, "web_search_call") }],
|
|
1165
|
+
details: { status: t.status, model, usage: t.usage, callTypes: t.callTypes },
|
|
1166
|
+
};
|
|
1167
|
+
},
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
pi.registerTool({
|
|
1171
|
+
name: "qwen_web_extractor",
|
|
1172
|
+
label: "Qwen Web Extractor",
|
|
1173
|
+
description:
|
|
1174
|
+
"Scrape and extract content from specific URLs via Qwen's Responses API web_extractor tool. " +
|
|
1175
|
+
"Returns the page text relevant to your stated goal. web_extractor requires web_search, so both are enabled.",
|
|
1176
|
+
promptSnippet: "Extract content from specific URLs toward a stated goal",
|
|
1177
|
+
promptGuidelines: [
|
|
1178
|
+
"Use qwen_web_extractor to read the content of a known URL when local curl is blocked or the page needs rendering/extraction, stating a clear goal.",
|
|
1179
|
+
],
|
|
1180
|
+
parameters: Type.Object({
|
|
1181
|
+
urls: Type.Array(Type.String(), { description: "One or more URLs to extract from." }),
|
|
1182
|
+
goal: Type.String({ description: "What information to pull out of those pages." }),
|
|
1183
|
+
model: Type.Optional(Type.String({ description: "Override model id." })),
|
|
1184
|
+
}),
|
|
1185
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1186
|
+
onUpdate?.({ content: [{ type: "text", text: `extracting ${params.urls.length} url(s)` }] });
|
|
1187
|
+
const model = pickResponsesModel(ctx, "web_extractor", params.model);
|
|
1188
|
+
const t = await responsesCall(ctx, {
|
|
1189
|
+
model,
|
|
1190
|
+
input: `Extract from these URLs: ${params.urls.join(", ")}\nGoal: ${params.goal}`,
|
|
1191
|
+
tools: ["web_search", "web_extractor"],
|
|
1192
|
+
maxTokens: 6144,
|
|
1193
|
+
signal,
|
|
1194
|
+
});
|
|
1195
|
+
return {
|
|
1196
|
+
content: [{ type: "text", text: formatResponses(t, "web_extractor_call") }],
|
|
1197
|
+
details: { status: t.status, model, urls: params.urls, usage: t.usage, callTypes: t.callTypes },
|
|
1198
|
+
};
|
|
1199
|
+
},
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
pi.registerTool({
|
|
1203
|
+
name: "qwen_code_interpreter",
|
|
1204
|
+
label: "Qwen Code Interpreter",
|
|
1205
|
+
description:
|
|
1206
|
+
"Execute Python in Alibaba's sandboxed server-side code interpreter. Returns the code it ran, " +
|
|
1207
|
+
"the container id, and the real stdout/stderr. Use for exact numeric, crypto, hash, or math results.",
|
|
1208
|
+
promptSnippet: "Run Python in Alibaba's sandboxed server-side code interpreter",
|
|
1209
|
+
promptGuidelines: [
|
|
1210
|
+
"Use qwen_code_interpreter when an answer needs real code execution rather than recall, such as SHA/MD5 digests, big-integer math, statistics over a pasted dataset, or confirming an algorithm's exact output.",
|
|
1211
|
+
"qwen_code_interpreter runs in an isolated Alibaba sandbox with no access to the local filesystem or network, so use bash for local work.",
|
|
1212
|
+
],
|
|
1213
|
+
parameters: Type.Object({
|
|
1214
|
+
task: Type.String({ description: "Self-contained instruction, including any input data inline." }),
|
|
1215
|
+
model: Type.Optional(Type.String({ description: "Override model id." })),
|
|
1216
|
+
}),
|
|
1217
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1218
|
+
onUpdate?.({ content: [{ type: "text", text: "running in sandbox..." }] });
|
|
1219
|
+
const model = pickResponsesModel(ctx, "code_interpreter", params.model);
|
|
1220
|
+
const t = await responsesCall(ctx, { model, input: params.task, tools: ["code_interpreter"], maxTokens: 6144, signal });
|
|
1221
|
+
return {
|
|
1222
|
+
content: [{ type: "text", text: formatResponses(t, "code_interpreter_call") }],
|
|
1223
|
+
details: { status: t.status, model, usage: t.usage, callTypes: t.callTypes },
|
|
1224
|
+
};
|
|
1225
|
+
},
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
// --------------------------------------------------------- qwen_image_search
|
|
1229
|
+
// Console label "t2i_search"; the API type string is web_search_image.
|
|
1230
|
+
// Sending "t2i_search" is silently ignored, like any unknown type.
|
|
1231
|
+
pi.registerTool({
|
|
1232
|
+
name: "qwen_image_search",
|
|
1233
|
+
label: "Qwen Image Search (text -> images)",
|
|
1234
|
+
description:
|
|
1235
|
+
"Search the web for images matching a text description, using Qwen's server-side web_search_image " +
|
|
1236
|
+
"tool. Returns the image URLs it found plus the queries it issued. Qwen runs the retrieval; results " +
|
|
1237
|
+
"are real URLs, not model guesses. Supported by qwen3.8-max only on this plan.",
|
|
1238
|
+
promptSnippet: "Find images on the web from a text description",
|
|
1239
|
+
promptGuidelines: [
|
|
1240
|
+
"Use qwen_image_search when the user wants pictures of something found on the web, as opposed to qwen_web_search which retrieves text sources.",
|
|
1241
|
+
"Report qwen_image_search results as links; the sandbox cannot render images inline.",
|
|
1242
|
+
],
|
|
1243
|
+
parameters: Type.Object({
|
|
1244
|
+
query: Type.String({ description: "What to find images of, as a short descriptive phrase." }),
|
|
1245
|
+
model: Type.Optional(Type.String({ description: "Override model id (only qwen3.8-max supports this tool)." })),
|
|
1246
|
+
}),
|
|
1247
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1248
|
+
onUpdate?.({ content: [{ type: "text", text: `image search: ${params.query}` }] });
|
|
1249
|
+
const model = pickResponsesModel(ctx, "web_search_image", params.model);
|
|
1250
|
+
const t = await responsesCall(ctx, { model, input: params.query, tools: ["web_search_image"], maxTokens: 4096, signal });
|
|
1251
|
+
return {
|
|
1252
|
+
content: [{ type: "text", text: formatResponses(t, "web_search_image_call") }],
|
|
1253
|
+
details: { status: t.status, model, usage: t.usage, callTypes: t.callTypes, imageUrls: t.imageUrls },
|
|
1254
|
+
};
|
|
1255
|
+
},
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
// ------------------------------------------------- qwen_reverse_image_search
|
|
1259
|
+
// Console label "i2i_search"; the API type string is image_search. The service
|
|
1260
|
+
// fetches the image itself, so even models that reject image *input* can drive it.
|
|
1261
|
+
pi.registerTool({
|
|
1262
|
+
name: "qwen_reverse_image_search",
|
|
1263
|
+
label: "Qwen Reverse Image Search (image -> images)",
|
|
1264
|
+
description:
|
|
1265
|
+
"Find visually similar images on the web for a given image, using Qwen's server-side image_search " +
|
|
1266
|
+
"tool. Accepts an http(s) URL, a data: URL, or a local file path (inlined as base64, 4 MB cap). " +
|
|
1267
|
+
"Useful for identifying what is in an image, finding sources, or locating higher-resolution versions.",
|
|
1268
|
+
promptSnippet: "Reverse image search: find similar images from a URL or local file",
|
|
1269
|
+
promptGuidelines: [
|
|
1270
|
+
"Use qwen_reverse_image_search when the user supplies an image and asks where it is from, what it depicts, or wants similar images.",
|
|
1271
|
+
"qwen_reverse_image_search takes a local path or URL; prefer a URL for files larger than 4 MB.",
|
|
1272
|
+
],
|
|
1273
|
+
parameters: Type.Object({
|
|
1274
|
+
image: Type.String({ description: "Image to search by: http(s) URL, data: URL, or local file path." }),
|
|
1275
|
+
question: Type.Optional(
|
|
1276
|
+
Type.String({ description: "Optional instruction shaping the search, e.g. 'find the original photo'." }),
|
|
1277
|
+
),
|
|
1278
|
+
model: Type.Optional(Type.String({ description: "Override model id." })),
|
|
1279
|
+
}),
|
|
1280
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1281
|
+
onUpdate?.({ content: [{ type: "text", text: "reverse image search..." }] });
|
|
1282
|
+
const model = pickResponsesModel(ctx, "image_search", params.model);
|
|
1283
|
+
const input: ResponsesInput = [
|
|
1284
|
+
{
|
|
1285
|
+
role: "user",
|
|
1286
|
+
content: [
|
|
1287
|
+
toImagePart(params.image),
|
|
1288
|
+
{
|
|
1289
|
+
type: "input_text",
|
|
1290
|
+
text: params.question?.trim() || "Find visually similar images and list their URLs.",
|
|
1291
|
+
},
|
|
1292
|
+
],
|
|
1293
|
+
},
|
|
1294
|
+
];
|
|
1295
|
+
const t = await responsesCall(ctx, { model, input, tools: ["image_search"], maxTokens: 4096, signal });
|
|
1296
|
+
return {
|
|
1297
|
+
content: [{ type: "text", text: formatResponses(t, "image_search_call") }],
|
|
1298
|
+
details: { status: t.status, model, usage: t.usage, callTypes: t.callTypes, imageUrls: t.imageUrls },
|
|
1299
|
+
};
|
|
1300
|
+
},
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
pi.registerTool({
|
|
1304
|
+
name: "qwen_deep_search",
|
|
1305
|
+
label: "Qwen Deep Search",
|
|
1306
|
+
description:
|
|
1307
|
+
"Qwen's multi-step 'agent' web search strategy: plans, issues several queries, and synthesises a " +
|
|
1308
|
+
"sourced answer. Much deeper than qwen_web_search but far more expensive (~300k prompt tokens " +
|
|
1309
|
+
"observed). Runs via chat/completions Agent mode, which cannot be combined with other tools.",
|
|
1310
|
+
promptSnippet: "Multi-step agentic web research with sources (expensive)",
|
|
1311
|
+
promptGuidelines: [
|
|
1312
|
+
"Use qwen_deep_search only for research needing several searches or cross-checking sources, such as tracing a breaking change or resolving conflicting documentation; prefer qwen_web_search for single-fact lookups because qwen_deep_search is very token-expensive.",
|
|
1313
|
+
],
|
|
1314
|
+
parameters: Type.Object({
|
|
1315
|
+
query: Type.String({ description: "The research question." }),
|
|
1316
|
+
model: Type.Optional(Type.String({ description: "Override model id." })),
|
|
1317
|
+
}),
|
|
1318
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1319
|
+
onUpdate?.({ content: [{ type: "text", text: "deep searching (agent strategy)..." }] });
|
|
1320
|
+
const model = pickChatCapable(ctx, params.model);
|
|
1321
|
+
const r = await agentModeCall(ctx, {
|
|
1322
|
+
model,
|
|
1323
|
+
prompt: params.query,
|
|
1324
|
+
maxTokens: config.deepSearchMaxTokens,
|
|
1325
|
+
signal,
|
|
1326
|
+
extra: {
|
|
1327
|
+
enable_search: true,
|
|
1328
|
+
search_options: { search_strategy: "agent", enable_source: true, enable_citation: true },
|
|
1329
|
+
},
|
|
1330
|
+
onUpdate: (t) => onUpdate?.({ content: [{ type: "text", text: t.slice(-2000) }] }),
|
|
1331
|
+
});
|
|
1332
|
+
const body = config.showReasoning && r.reasoning ? `\n\n<reasoning>\n${r.reasoning}\n</reasoning>` : "";
|
|
1333
|
+
const meta = r.usage ? `\n\n[tokens: prompt=${r.usage.prompt_tokens ?? "?"} total=${r.usage.total_tokens ?? "?"}]` : "";
|
|
1334
|
+
return { content: [{ type: "text", text: `${r.text}${body}${meta}` }], details: { model, usage: r.usage } };
|
|
1335
|
+
},
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
// ------------------------------------------------------ qwen_generate_image
|
|
1339
|
+
pi.registerTool({
|
|
1340
|
+
name: "qwen_generate_image",
|
|
1341
|
+
label: "Qwen Image Generation",
|
|
1342
|
+
description:
|
|
1343
|
+
"Generate images with the Qwen plan's image models (wan2.7-image, wan2.7-image-pro, qwen-image-3.0-pro) " +
|
|
1344
|
+
"through the native multimodal-generation endpoint. These are not chat models, so they are unreachable " +
|
|
1345
|
+
"from pi's normal model list. Returns temporary hosted image URLs.",
|
|
1346
|
+
promptSnippet: "Generate images with Qwen/Wan image models",
|
|
1347
|
+
promptGuidelines: [
|
|
1348
|
+
"Use qwen_generate_image when the user asks to create or draw an image; report the returned URLs as links and note they are temporary hosted files.",
|
|
1349
|
+
"qwen_generate_image cannot render images in the terminal, so hand the user the URL rather than describing pixels you cannot see.",
|
|
1350
|
+
],
|
|
1351
|
+
parameters: Type.Object({
|
|
1352
|
+
prompt: Type.String({ description: "What to draw." }),
|
|
1353
|
+
model: Type.Optional(Type.String({ description: `Override model. One of: ${IMAGE_MODELS.join(", ")}.` })),
|
|
1354
|
+
negativePrompt: Type.Optional(Type.String({ description: "What to avoid." })),
|
|
1355
|
+
size: Type.Optional(Type.String({ description: "Pixel size, e.g. 1024*1024. Must be 589824-16777216 total pixels (768² to 4096²)." })),
|
|
1356
|
+
save: Type.Optional(Type.Boolean({ description: "Also write the images into <cwd>/out/qtools/ instead of only returning expiring URLs." })),
|
|
1357
|
+
}),
|
|
1358
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1359
|
+
onUpdate?.({ content: [{ type: "text", text: "generating image..." }] });
|
|
1360
|
+
const r = await generateImage(ctx, { ...params, signal });
|
|
1361
|
+
let text = r.urls.length
|
|
1362
|
+
? formatUrls("Generated images", r.model, r.urls)
|
|
1363
|
+
: `No image URL returned by ${r.model}:\n${JSON.stringify(r.raw).slice(0, 600)}`;
|
|
1364
|
+
let files: string[] = [];
|
|
1365
|
+
if (params.save && r.urls.length) {
|
|
1366
|
+
files = await Promise.all(r.urls.map((u) => saveMedia(ctx, u, r.model, signal)));
|
|
1367
|
+
text += `\n\nSaved:\n${files.map((f) => ` ${f}`).join("\n")}`;
|
|
1368
|
+
}
|
|
1369
|
+
return { content: [{ type: "text", text }], details: { model: r.model, urls: r.urls, files } };
|
|
1370
|
+
},
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
// ------------------------------------------------------ qwen_generate_video
|
|
1374
|
+
pi.registerTool({
|
|
1375
|
+
name: "qwen_generate_video",
|
|
1376
|
+
label: "Qwen Video Generation",
|
|
1377
|
+
description:
|
|
1378
|
+
"Generate a short video with the plan's HappyHorse models using the async task API (submit then poll). " +
|
|
1379
|
+
"Blocks until the task finishes, which took ~90-120s in testing. t2v animates from a text prompt; " +
|
|
1380
|
+
"i2v animates from a starting frame and r2v from a reference image, so pass the `image` parameter for those " +
|
|
1381
|
+
"(http(s) URL, data: URL, or local file path). Passing an image to t2v switches it to i2v automatically. " +
|
|
1382
|
+
"Note the endpoint returns HTTP 200 on submit even for a malformed request, so failures only appear in the " +
|
|
1383
|
+
"polled result; read the returned status rather than assuming success.",
|
|
1384
|
+
promptSnippet: "Generate a short video from text or a source image (slow)",
|
|
1385
|
+
promptGuidelines: [
|
|
1386
|
+
"Use qwen_generate_video only when the user explicitly asks for generated video, and warn them it takes a couple of minutes since it blocks.",
|
|
1387
|
+
"For animating an existing picture, pass qwen_generate_video the image parameter; happyhorse-1.1-i2v needs at least a 300x300 source image.",
|
|
1388
|
+
],
|
|
1389
|
+
parameters: Type.Object({
|
|
1390
|
+
prompt: Type.String({ description: "Scene and motion description." }),
|
|
1391
|
+
image: Type.Optional(
|
|
1392
|
+
Type.String({
|
|
1393
|
+
description:
|
|
1394
|
+
"Source image for i2v (first frame) or r2v (reference). http(s) URL, data: URL, or local file path. Min 300x300.",
|
|
1395
|
+
}),
|
|
1396
|
+
),
|
|
1397
|
+
model: Type.Optional(Type.String({ description: `Override model. One of: ${VIDEO_MODELS.join(", ")}.` })),
|
|
1398
|
+
size: Type.Optional(Type.String({ description: `One of: ${VIDEO_SIZES.join(", ")}. Advisory only for i2v/r2v: output follows the source image aspect ratio (a square frame returned 1440x1440).` })),
|
|
1399
|
+
save: Type.Optional(Type.Boolean({ description: "Also write the clip into <cwd>/out/qtools/ instead of only returning an expiring URL." })),
|
|
1400
|
+
}),
|
|
1401
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1402
|
+
onUpdate?.({ content: [{ type: "text", text: params.image ? "submitting image-to-video task..." : "submitting video task..." }] });
|
|
1403
|
+
const r = await generateVideo(ctx, {
|
|
1404
|
+
...params,
|
|
1405
|
+
signal,
|
|
1406
|
+
onTick: (st, sec) => onUpdate?.({ content: [{ type: "text", text: `video ${st} (${sec}s)` }] }),
|
|
1407
|
+
});
|
|
1408
|
+
const text =
|
|
1409
|
+
r.status === "SUCCEEDED"
|
|
1410
|
+
? formatUrls("Generated video", r.model, r.url ? [r.url] : []) + `\n(task ${r.taskId})`
|
|
1411
|
+
: `Video task ${r.status} (task ${r.taskId})${r.detail ? `\n ${r.detail}` : ""}`;
|
|
1412
|
+
let file: string | undefined;
|
|
1413
|
+
if (params.save && r.status === "SUCCEEDED" && r.url) {
|
|
1414
|
+
file = await saveMedia(ctx, r.url, r.model, signal).catch((e) => `save failed: ${e.message}`);
|
|
1415
|
+
}
|
|
1416
|
+
return {
|
|
1417
|
+
content: [{ type: "text", text: file ? `${text}\n\nSaved:\n ${file}` : text }],
|
|
1418
|
+
details: { ...r, file },
|
|
1419
|
+
};
|
|
1420
|
+
},
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
// ---------------------------------------------------------------- commands
|
|
1424
|
+
pi.registerCommand("qimage", {
|
|
1425
|
+
description: `Generate an image — /qimage <prompt> [-m model] [-s WxH] (default: ${config.imageModel})`,
|
|
1426
|
+
getArgumentCompletions: (prefix) => genCompletions(prefix, { models: IMAGE_MODELS, sizes: [], image: false }),
|
|
1427
|
+
handler: async (args, ctx) => {
|
|
1428
|
+
if (wantsHelp(args)) {
|
|
1429
|
+
ctx.ui.notify(genUsage("image", ctx.cwd), "info");
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
const { prompt, model, size } = parseGenArgs(args, IMAGE_MODELS);
|
|
1433
|
+
if (!prompt) {
|
|
1434
|
+
ctx.ui.notify(genUsage("image", ctx.cwd), "error");
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
ctx.ui.notify(`Generating image with ${model || config.imageModel}...`, "info");
|
|
1438
|
+
const r = await generateImage(ctx, { prompt, model, size });
|
|
1439
|
+
if (!r.urls.length) {
|
|
1440
|
+
ctx.ui.notify(`No image URL from ${r.model}`, "error");
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
// Save locally: the signed URLs wrap across terminal rows and are unreliable
|
|
1444
|
+
// to copy out of the panel.
|
|
1445
|
+
const saved = await Promise.all(r.urls.map((u) => saveMedia(ctx, u, r.model)));
|
|
1446
|
+
ctx.ui.notify(`Image from ${r.model} saved:\n${saved.map((s) => " " + s).join("\n")}`, "success");
|
|
1447
|
+
},
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
pi.registerCommand("qvideo", {
|
|
1451
|
+
description: `Generate a video — /qvideo <prompt> [-i img] [-m model] [-s WxH] (default: ${config.videoModel})`,
|
|
1452
|
+
getArgumentCompletions: (prefix) => genCompletions(prefix, { models: VIDEO_MODELS, sizes: VIDEO_SIZES, image: true }),
|
|
1453
|
+
handler: async (args, ctx) => {
|
|
1454
|
+
if (wantsHelp(args)) {
|
|
1455
|
+
ctx.ui.notify(genUsage("video", ctx.cwd), "info");
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
const { prompt, model, size, image } = parseGenArgs(args, VIDEO_MODELS);
|
|
1459
|
+
if (!prompt) {
|
|
1460
|
+
ctx.ui.notify(genUsage("video", ctx.cwd), "error");
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const r = await generateVideo(ctx, {
|
|
1464
|
+
prompt,
|
|
1465
|
+
model,
|
|
1466
|
+
size,
|
|
1467
|
+
image,
|
|
1468
|
+
onTick: (st) => ctx.ui.notify(`video ${st}`, "info"),
|
|
1469
|
+
});
|
|
1470
|
+
if (r.status === "SUCCEEDED" && r.url) {
|
|
1471
|
+
const saved = await saveMedia(ctx, r.url, r.model).catch((e) => `save failed: ${e.message}`);
|
|
1472
|
+
ctx.ui.notify(`Video from ${r.model}:\n ${saved}`, "success");
|
|
1473
|
+
} else {
|
|
1474
|
+
// Submits always 200, so the polled detail is the only real error signal.
|
|
1475
|
+
ctx.ui.notify(
|
|
1476
|
+
`Video task ${r.status} (task ${r.taskId})${r.detail ? `\n ${r.detail}` : ""}`,
|
|
1477
|
+
"error",
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
},
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
pi.registerCommand("qtools-config", {
|
|
1484
|
+
description: "Configure Qwen web search, built-in tools, and model fallback",
|
|
1485
|
+
handler: async (_args, ctx) => {
|
|
1486
|
+
if (ctx.mode !== "tui") {
|
|
1487
|
+
ctx.ui.notify("/qtools-config requires TUI mode. Config file: " + configPath(), "info");
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
await ctx.ui.custom((_tui, th, _kb, done) => {
|
|
1491
|
+
const items = buildItems(ctx, th);
|
|
1492
|
+
const container = new Container();
|
|
1493
|
+
container.addChild(new Text(th.fg("accent", th.bold("Qwen Tools Configuration")), 0, 0));
|
|
1494
|
+
container.addChild(new Spacer(1));
|
|
1495
|
+
|
|
1496
|
+
const list = new SettingsList(
|
|
1497
|
+
items,
|
|
1498
|
+
Math.min(items.length + 2, 14),
|
|
1499
|
+
getSettingsListTheme(),
|
|
1500
|
+
(id, value) => {
|
|
1501
|
+
applyChange(id, value);
|
|
1502
|
+
for (const row of items) {
|
|
1503
|
+
row.description = descriptionFor(row.id, ctx);
|
|
1504
|
+
list.updateValue(row.id, row.currentValue);
|
|
1505
|
+
}
|
|
1506
|
+
_tui.requestRender();
|
|
1507
|
+
},
|
|
1508
|
+
() => {
|
|
1509
|
+
if (lastSaveError) ctx.ui.notify(`Could not save config: ${lastSaveError}`, "error");
|
|
1510
|
+
done(undefined);
|
|
1511
|
+
},
|
|
1512
|
+
{ enableSearch: true },
|
|
1513
|
+
);
|
|
1514
|
+
|
|
1515
|
+
container.addChild(list);
|
|
1516
|
+
|
|
1517
|
+
return {
|
|
1518
|
+
render: (width: number) => container.render(width),
|
|
1519
|
+
invalidate: () => container.invalidate(),
|
|
1520
|
+
handleInput: (data: string) => {
|
|
1521
|
+
list.handleInput(data);
|
|
1522
|
+
_tui.requestRender();
|
|
1523
|
+
},
|
|
1524
|
+
};
|
|
1525
|
+
});
|
|
1526
|
+
},
|
|
1527
|
+
});
|
|
1528
|
+
}
|