@jackwener/opencli 1.7.6 → 1.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -8
- package/README.zh-CN.md +14 -8
- package/cli-manifest.json +469 -11
- package/clis/51job/company.js +125 -0
- package/clis/51job/detail.js +108 -0
- package/clis/51job/hot.js +55 -0
- package/clis/51job/search.js +79 -0
- package/clis/51job/utils.js +302 -0
- package/clis/51job/utils.test.js +69 -0
- package/clis/amazon/discussion.js +37 -6
- package/clis/amazon/discussion.test.js +147 -32
- package/clis/bilibili/video.js +11 -4
- package/clis/bilibili/video.test.js +51 -0
- package/clis/chatgpt/image.js +1 -1
- package/clis/chatgpt-app/ask.js +3 -19
- package/clis/chatgpt-app/ax.js +132 -1
- package/clis/chatgpt-app/ax.test.js +23 -0
- package/clis/chatgpt-app/send.js +2 -21
- package/clis/deepseek/ask.js +50 -18
- package/clis/deepseek/ask.test.js +195 -2
- package/clis/deepseek/utils.js +113 -29
- package/clis/deepseek/utils.test.js +109 -1
- package/clis/gemini/image.js +1 -1
- package/clis/instagram/download.js +1 -1
- package/clis/powerchina/search.js +250 -0
- package/clis/powerchina/search.test.js +67 -0
- package/clis/sinafinance/stock.js +5 -2
- package/clis/sinafinance/stock.test.js +59 -0
- package/clis/toutiao/articles.js +81 -0
- package/clis/toutiao/articles.test.js +23 -0
- package/clis/twitter/likes.js +3 -2
- package/clis/twitter/search.js +4 -2
- package/clis/twitter/search.test.js +4 -0
- package/clis/twitter/shared.js +28 -0
- package/clis/twitter/shared.test.js +96 -0
- package/clis/twitter/thread.js +3 -1
- package/clis/twitter/timeline.js +3 -2
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/web/read.js +25 -5
- package/clis/web/read.test.js +76 -0
- package/clis/weixin/create-draft.js +225 -0
- package/clis/weixin/drafts.js +65 -0
- package/clis/weixin/drafts.test.js +65 -0
- package/clis/weread/ai-outline.js +170 -0
- package/clis/weread/ai-outline.test.js +83 -0
- package/clis/weread/book.js +57 -44
- package/clis/weread/commands.test.js +24 -0
- package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
- package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
- package/dist/src/browser/analyze.d.ts +103 -0
- package/dist/src/browser/analyze.js +230 -0
- package/dist/src/browser/analyze.test.d.ts +1 -0
- package/dist/src/browser/analyze.test.js +164 -0
- package/dist/src/browser/article-extract.d.ts +57 -0
- package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
- package/dist/src/browser/article-extract.e2e.test.js +105 -0
- package/dist/src/browser/article-extract.js +169 -0
- package/dist/src/browser/article-extract.test.d.ts +1 -0
- package/dist/src/browser/article-extract.test.js +94 -0
- package/dist/src/browser/cdp.js +11 -2
- package/dist/src/browser/verify-fixture.d.ts +59 -0
- package/dist/src/browser/verify-fixture.js +213 -0
- package/dist/src/browser/verify-fixture.test.d.ts +1 -0
- package/dist/src/browser/verify-fixture.test.js +161 -0
- package/dist/src/cli.d.ts +32 -0
- package/dist/src/cli.js +333 -43
- package/dist/src/cli.test.js +257 -1
- package/dist/src/commanderAdapter.js +12 -0
- package/dist/src/commanderAdapter.test.js +11 -0
- package/dist/src/daemon.d.ts +3 -2
- package/dist/src/daemon.js +16 -4
- package/dist/src/daemon.test.d.ts +1 -0
- package/dist/src/daemon.test.js +19 -0
- package/dist/src/download/article-download.d.ts +12 -0
- package/dist/src/download/article-download.js +141 -17
- package/dist/src/download/article-download.test.js +196 -0
- package/dist/src/download/index.js +73 -86
- package/dist/src/errors.js +4 -2
- package/dist/src/errors.test.js +13 -0
- package/dist/src/launcher.d.ts +1 -1
- package/dist/src/launcher.js +3 -3
- package/dist/src/output.js +1 -1
- package/dist/src/output.test.js +6 -0
- package/package.json +5 -1
package/clis/deepseek/ask.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
|
|
3
3
|
import {
|
|
4
4
|
DEEPSEEK_DOMAIN, DEEPSEEK_URL, ensureOnDeepSeek, selectModel, setFeature,
|
|
5
5
|
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
|
@@ -23,7 +23,7 @@ export const askCommand = cli({
|
|
|
23
23
|
{ name: 'search', type: 'boolean', default: false, help: 'Enable web search' },
|
|
24
24
|
{ name: 'file', help: 'Attach a file (PDF, image, text) with the prompt' },
|
|
25
25
|
],
|
|
26
|
-
columns:
|
|
26
|
+
// columns omitted: derived from row keys so non-think output shows only 'response'
|
|
27
27
|
|
|
28
28
|
func: async (page, kwargs) => {
|
|
29
29
|
const prompt = kwargs.prompt;
|
|
@@ -35,29 +35,55 @@ export const askCommand = cli({
|
|
|
35
35
|
await page.goto(DEEPSEEK_URL);
|
|
36
36
|
await page.wait(3);
|
|
37
37
|
} else {
|
|
38
|
-
await ensureOnDeepSeek(page);
|
|
38
|
+
const navigated = await ensureOnDeepSeek(page);
|
|
39
|
+
if (navigated) {
|
|
40
|
+
// Workspace was recycled; try to resume the most recent
|
|
41
|
+
// conversation instead of starting a new one.
|
|
42
|
+
await page.evaluate(`(() => {
|
|
43
|
+
var link = document.querySelector('a[href*="/a/chat/s/"]');
|
|
44
|
+
if (link) link.click();
|
|
45
|
+
})()`);
|
|
46
|
+
await page.wait(2);
|
|
47
|
+
}
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
await page.wait(2);
|
|
42
51
|
|
|
52
|
+
// Model selector is only available on the new-chat page, not inside
|
|
53
|
+
// an existing conversation. Skip it when we resumed a prior thread.
|
|
54
|
+
const currentUrl = await page.evaluate('window.location.href') || '';
|
|
55
|
+
const inConversation = currentUrl.includes('/a/chat/s/');
|
|
56
|
+
const modelExplicit = kwargs.__opencliOptionSources?.model === 'cli';
|
|
57
|
+
|
|
43
58
|
const wantModel = kwargs.model || 'instant';
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
59
|
+
if (inConversation && modelExplicit) {
|
|
60
|
+
throw new CliError(
|
|
61
|
+
'ARGUMENT',
|
|
62
|
+
`Cannot switch to ${wantModel} model inside an existing conversation.`,
|
|
63
|
+
'Re-run with --new to start a fresh chat before selecting a model.',
|
|
64
|
+
EXIT_CODES.USAGE_ERROR,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!inConversation) {
|
|
69
|
+
const modelResult = await withRetry(() => selectModel(page, wantModel));
|
|
70
|
+
if (!modelResult?.ok) {
|
|
71
|
+
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
|
|
72
|
+
}
|
|
73
|
+
if (modelResult?.toggled) await page.wait(0.5);
|
|
47
74
|
}
|
|
48
|
-
if (modelResult.toggled) await page.wait(0.5);
|
|
49
75
|
|
|
50
76
|
const thinkResult = await withRetry(() => setFeature(page, 'DeepThink', wantThink));
|
|
51
|
-
if (!thinkResult?.ok) {
|
|
52
|
-
throw new CommandExecutionError('Could not
|
|
77
|
+
if (!thinkResult?.ok && wantThink) {
|
|
78
|
+
throw new CommandExecutionError('Could not enable DeepThink');
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
const searchResult = await withRetry(() => setFeature(page, 'Search', wantSearch));
|
|
56
|
-
if (!searchResult?.ok) {
|
|
57
|
-
throw new CommandExecutionError('Could not
|
|
82
|
+
if (!searchResult?.ok && wantSearch) {
|
|
83
|
+
throw new CommandExecutionError('Could not enable Search');
|
|
58
84
|
}
|
|
59
85
|
|
|
60
|
-
if (thinkResult
|
|
86
|
+
if (thinkResult?.toggled || searchResult?.toggled) await page.wait(0.5);
|
|
61
87
|
|
|
62
88
|
if (kwargs.file) {
|
|
63
89
|
const baseline = await withRetry(() => getBubbleCount(page));
|
|
@@ -71,11 +97,14 @@ export const askCommand = cli({
|
|
|
71
97
|
if (!String(err?.message || err).includes('Promise was collected')) throw err;
|
|
72
98
|
}
|
|
73
99
|
await page.wait(3);
|
|
74
|
-
const
|
|
75
|
-
if (!
|
|
100
|
+
const result = await waitForResponse(page, baseline, prompt, timeoutMs, wantThink);
|
|
101
|
+
if (!result) {
|
|
76
102
|
return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
|
|
77
103
|
}
|
|
78
|
-
|
|
104
|
+
if (wantThink && typeof result === 'object' && result.response !== undefined) {
|
|
105
|
+
return [result];
|
|
106
|
+
}
|
|
107
|
+
return [{ response: result }];
|
|
79
108
|
}
|
|
80
109
|
|
|
81
110
|
const baseline = await withRetry(() => getBubbleCount(page));
|
|
@@ -84,11 +113,14 @@ export const askCommand = cli({
|
|
|
84
113
|
throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
|
|
85
114
|
}
|
|
86
115
|
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
116
|
+
const result = await waitForResponse(page, baseline, prompt, timeoutMs, wantThink);
|
|
117
|
+
if (!result) {
|
|
89
118
|
return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
|
|
90
119
|
}
|
|
91
120
|
|
|
92
|
-
|
|
121
|
+
if (wantThink && typeof result === 'object' && result.response !== undefined) {
|
|
122
|
+
return [result];
|
|
123
|
+
}
|
|
124
|
+
return [{ response: result }];
|
|
93
125
|
},
|
|
94
126
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/errors';
|
|
2
3
|
|
|
3
4
|
const {
|
|
4
5
|
mockEnsureOnDeepSeek,
|
|
@@ -42,11 +43,13 @@ describe('deepseek ask --file', () => {
|
|
|
42
43
|
const page = {
|
|
43
44
|
wait: vi.fn().mockResolvedValue(undefined),
|
|
44
45
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
46
|
+
evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
|
|
45
47
|
};
|
|
46
48
|
|
|
47
49
|
beforeEach(() => {
|
|
48
50
|
vi.clearAllMocks();
|
|
49
|
-
|
|
51
|
+
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
|
|
52
|
+
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
|
50
53
|
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
51
54
|
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
|
52
55
|
mockSendWithFile.mockResolvedValue({ ok: true });
|
|
@@ -68,6 +71,196 @@ describe('deepseek ask --file', () => {
|
|
|
68
71
|
expect(rows).toEqual([{ response: 'new reply' }]);
|
|
69
72
|
expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
|
|
70
73
|
expect(mockSendWithFile).toHaveBeenCalledWith(page, './report.pdf', 'summarize this');
|
|
71
|
-
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 7, 'summarize this', 120000);
|
|
74
|
+
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 7, 'summarize this', 120000, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('still fails when explicit instant model selection cannot be verified', async () => {
|
|
78
|
+
mockSelectModel.mockResolvedValue({ ok: false });
|
|
79
|
+
|
|
80
|
+
await expect(askCommand.func(page, {
|
|
81
|
+
prompt: 'summarize this',
|
|
82
|
+
timeout: 120,
|
|
83
|
+
new: false,
|
|
84
|
+
model: 'instant',
|
|
85
|
+
think: false,
|
|
86
|
+
search: false,
|
|
87
|
+
})).rejects.toThrow(new CommandExecutionError('Could not switch to instant model'));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('deepseek ask --think', () => {
|
|
92
|
+
const page = {
|
|
93
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
94
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
95
|
+
evaluate: vi.fn().mockResolvedValue('https://chat.deepseek.com/'),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
vi.clearAllMocks();
|
|
100
|
+
page.evaluate.mockResolvedValue('https://chat.deepseek.com/');
|
|
101
|
+
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
|
102
|
+
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
103
|
+
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
|
104
|
+
mockSendMessage.mockResolvedValue({ ok: true });
|
|
105
|
+
mockGetBubbleCount.mockResolvedValue(5);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns separate thinking and response fields when --think is enabled', async () => {
|
|
109
|
+
mockWaitForResponse.mockResolvedValue({
|
|
110
|
+
response: 'The answer is 42.',
|
|
111
|
+
thinking: 'Let me analyze this...',
|
|
112
|
+
thinking_time: '2.5',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const rows = await askCommand.func(page, {
|
|
116
|
+
prompt: 'what is the answer?',
|
|
117
|
+
timeout: 120,
|
|
118
|
+
new: false,
|
|
119
|
+
model: 'instant',
|
|
120
|
+
think: true,
|
|
121
|
+
search: false,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(rows).toEqual([{
|
|
125
|
+
response: 'The answer is 42.',
|
|
126
|
+
thinking: 'Let me analyze this...',
|
|
127
|
+
thinking_time: '2.5',
|
|
128
|
+
}]);
|
|
129
|
+
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('returns plain response when --think is disabled', async () => {
|
|
133
|
+
mockWaitForResponse.mockResolvedValue('The answer is 42.');
|
|
134
|
+
|
|
135
|
+
const rows = await askCommand.func(page, {
|
|
136
|
+
prompt: 'what is the answer?',
|
|
137
|
+
timeout: 120,
|
|
138
|
+
new: false,
|
|
139
|
+
model: 'instant',
|
|
140
|
+
think: false,
|
|
141
|
+
search: false,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(rows).toEqual([{ response: 'The answer is 42.' }]);
|
|
145
|
+
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 5, 'what is the answer?', 120000, false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('does not declare static columns (derived from row keys)', () => {
|
|
149
|
+
// columns should be undefined so the renderer infers from row keys,
|
|
150
|
+
// avoiding empty trailing columns on non-think output.
|
|
151
|
+
expect(askCommand.columns).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('non-think rows only contain response key', async () => {
|
|
155
|
+
mockWaitForResponse.mockResolvedValue('Plain answer.');
|
|
156
|
+
|
|
157
|
+
const rows = await askCommand.func(page, {
|
|
158
|
+
prompt: 'hello',
|
|
159
|
+
timeout: 120,
|
|
160
|
+
new: false,
|
|
161
|
+
model: 'instant',
|
|
162
|
+
think: false,
|
|
163
|
+
search: false,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Row keys drive rendered columns; no thinking/thinking_time present.
|
|
167
|
+
expect(Object.keys(rows[0])).toEqual(['response']);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('deepseek ask conversation resume', () => {
|
|
172
|
+
const page = {
|
|
173
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
174
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
175
|
+
evaluate: vi.fn(),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
vi.clearAllMocks();
|
|
180
|
+
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
|
181
|
+
mockSendMessage.mockResolvedValue({ ok: true });
|
|
182
|
+
mockGetBubbleCount.mockResolvedValue(2);
|
|
183
|
+
mockWaitForResponse.mockResolvedValue('follow-up reply');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('resumes the most recent conversation and skips model selection', async () => {
|
|
187
|
+
mockEnsureOnDeepSeek.mockResolvedValue(true);
|
|
188
|
+
// first evaluate: sidebar resume click (returns undefined)
|
|
189
|
+
page.evaluate.mockResolvedValueOnce(undefined);
|
|
190
|
+
// second evaluate: URL check (now inside a conversation)
|
|
191
|
+
page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/a/chat/s/abc-123');
|
|
192
|
+
|
|
193
|
+
const rows = await askCommand.func(page, {
|
|
194
|
+
prompt: 'follow up',
|
|
195
|
+
timeout: 120,
|
|
196
|
+
new: false,
|
|
197
|
+
model: 'instant',
|
|
198
|
+
think: false,
|
|
199
|
+
search: false,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(rows).toEqual([{ response: 'follow-up reply' }]);
|
|
203
|
+
expect(mockSelectModel).not.toHaveBeenCalled();
|
|
204
|
+
expect(mockSendMessage).toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('skips model selection when already inside an existing conversation', async () => {
|
|
208
|
+
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
|
209
|
+
page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
|
|
210
|
+
|
|
211
|
+
const rows = await askCommand.func(page, {
|
|
212
|
+
prompt: 'continue',
|
|
213
|
+
timeout: 120,
|
|
214
|
+
new: false,
|
|
215
|
+
model: 'expert',
|
|
216
|
+
think: false,
|
|
217
|
+
search: false,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(rows).toEqual([{ response: 'follow-up reply' }]);
|
|
221
|
+
expect(mockSelectModel).not.toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('fails fast when --model is explicitly requested inside an existing conversation', async () => {
|
|
225
|
+
mockEnsureOnDeepSeek.mockResolvedValue(false);
|
|
226
|
+
page.evaluate.mockResolvedValue('https://chat.deepseek.com/a/chat/s/abc-123');
|
|
227
|
+
|
|
228
|
+
await expect(askCommand.func(page, {
|
|
229
|
+
prompt: 'continue',
|
|
230
|
+
timeout: 120,
|
|
231
|
+
new: false,
|
|
232
|
+
model: 'expert',
|
|
233
|
+
think: false,
|
|
234
|
+
search: false,
|
|
235
|
+
__opencliOptionSources: { model: 'cli' },
|
|
236
|
+
})).rejects.toMatchObject(new CliError(
|
|
237
|
+
'ARGUMENT',
|
|
238
|
+
'Cannot switch to expert model inside an existing conversation.',
|
|
239
|
+
'Re-run with --new to start a fresh chat before selecting a model.',
|
|
240
|
+
EXIT_CODES.USAGE_ERROR,
|
|
241
|
+
));
|
|
242
|
+
|
|
243
|
+
expect(mockSelectModel).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('still selects model when no conversation to resume', async () => {
|
|
247
|
+
mockEnsureOnDeepSeek.mockResolvedValue(true);
|
|
248
|
+
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
249
|
+
// first evaluate: sidebar resume click (no link found)
|
|
250
|
+
page.evaluate.mockResolvedValueOnce(undefined);
|
|
251
|
+
// second evaluate: URL check (still on root page)
|
|
252
|
+
page.evaluate.mockResolvedValueOnce('https://chat.deepseek.com/');
|
|
253
|
+
|
|
254
|
+
const rows = await askCommand.func(page, {
|
|
255
|
+
prompt: 'hello',
|
|
256
|
+
timeout: 120,
|
|
257
|
+
new: false,
|
|
258
|
+
model: 'instant',
|
|
259
|
+
think: false,
|
|
260
|
+
search: false,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(rows).toEqual([{ response: 'follow-up reply' }]);
|
|
264
|
+
expect(mockSelectModel).toHaveBeenCalled();
|
|
72
265
|
});
|
|
73
266
|
});
|
package/clis/deepseek/utils.js
CHANGED
|
@@ -15,10 +15,10 @@ export async function isOnDeepSeek(page) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export async function ensureOnDeepSeek(page) {
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
if (await isOnDeepSeek(page)) return false;
|
|
19
|
+
await page.goto(DEEPSEEK_URL);
|
|
20
|
+
await page.wait(3);
|
|
21
|
+
return true;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export async function getPageState(page) {
|
|
@@ -38,31 +38,27 @@ export async function getPageState(page) {
|
|
|
38
38
|
|
|
39
39
|
export async function selectModel(page, modelName) {
|
|
40
40
|
return page.evaluate(`(() => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
return { ok: false };
|
|
41
|
+
var radios = document.querySelectorAll('div[role="radio"]');
|
|
42
|
+
if (radios.length === 0) return { ok: false };
|
|
43
|
+
var isFirst = '${modelName}'.toLowerCase() === 'instant';
|
|
44
|
+
if (!isFirst && radios.length < 2) return { ok: false };
|
|
45
|
+
var target = isFirst ? radios[0] : radios[radios.length - 1];
|
|
46
|
+
var alreadySelected = target.getAttribute('aria-checked') === 'true';
|
|
47
|
+
if (!alreadySelected) target.click();
|
|
48
|
+
return { ok: true, toggled: !alreadySelected };
|
|
51
49
|
})()`);
|
|
52
50
|
}
|
|
53
51
|
|
|
54
52
|
export async function setFeature(page, featureName, enabled) {
|
|
53
|
+
// Match by position: DeepThink is the first toggle, Search is the second
|
|
54
|
+
var index = featureName === 'DeepThink' ? 0 : 1;
|
|
55
55
|
return page.evaluate(`(() => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
return { ok: true, toggled: ${enabled} !== isActive };
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return { ok: false };
|
|
56
|
+
var toggles = Array.from(document.querySelectorAll('.ds-toggle-button'));
|
|
57
|
+
var btn = toggles[${index}];
|
|
58
|
+
if (!btn) return { ok: false };
|
|
59
|
+
var isActive = btn.classList.contains('ds-toggle-button--selected');
|
|
60
|
+
if (${enabled} !== isActive) btn.click();
|
|
61
|
+
return { ok: true, toggled: ${enabled} !== isActive };
|
|
66
62
|
})()`);
|
|
67
63
|
}
|
|
68
64
|
|
|
@@ -101,7 +97,35 @@ export async function getBubbleCount(page) {
|
|
|
101
97
|
return count || 0;
|
|
102
98
|
}
|
|
103
99
|
|
|
104
|
-
|
|
100
|
+
// Parse thinking response using text as a fallback when DOM-level extraction
|
|
101
|
+
// is not available. Does NOT split on \n\n — that heuristic silently corrupts
|
|
102
|
+
// multi-paragraph thinking or multi-paragraph answers. Instead, everything
|
|
103
|
+
// after the header is treated as thinking content, and `response` stays empty
|
|
104
|
+
// until the caller provides a DOM-separated answer.
|
|
105
|
+
export function parseThinkingResponse(rawText) {
|
|
106
|
+
if (!rawText) return null;
|
|
107
|
+
|
|
108
|
+
// Match thinking header patterns: "Thought for X seconds" or "已思考(用时 X 秒)"
|
|
109
|
+
const thinkHeaderMatch = rawText.match(/^(Thought for ([\d.]+) seconds?|已思考(用时 ([\d.]+) 秒))\s*/);
|
|
110
|
+
|
|
111
|
+
if (!thinkHeaderMatch) {
|
|
112
|
+
// No thinking section found, return plain response
|
|
113
|
+
return { response: rawText, thinking: null, thinking_time: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const thinkingTime = thinkHeaderMatch[2] || thinkHeaderMatch[3];
|
|
117
|
+
const afterHeader = rawText.slice(thinkHeaderMatch[0].length);
|
|
118
|
+
|
|
119
|
+
// Treat everything after the header as thinking. The response will be
|
|
120
|
+
// populated by the DOM-level extraction in waitForResponse().
|
|
121
|
+
return {
|
|
122
|
+
response: '',
|
|
123
|
+
thinking: afterHeader.trim(),
|
|
124
|
+
thinking_time: thinkingTime,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function waitForResponse(page, baselineCount, prompt, timeoutMs, parseThinking = false) {
|
|
105
129
|
const startTime = Date.now();
|
|
106
130
|
let lastText = '';
|
|
107
131
|
let stableCount = 0;
|
|
@@ -114,7 +138,51 @@ export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
|
|
|
114
138
|
result = await page.evaluate(`(() => {
|
|
115
139
|
const bubbles = document.querySelectorAll('${MESSAGE_SELECTOR}');
|
|
116
140
|
const texts = Array.from(bubbles).map(b => (b.innerText || '').trim()).filter(Boolean);
|
|
117
|
-
|
|
141
|
+
var last = texts[texts.length - 1] || '';
|
|
142
|
+
|
|
143
|
+
// DOM-level thinking/response separation.
|
|
144
|
+
// DeepSeek renders thinking in a collapsible container with a
|
|
145
|
+
// distinct class (e.g. .ds-markdown--think or similar) and the
|
|
146
|
+
// final answer in the main .ds-markdown region. By querying
|
|
147
|
+
// these separately we avoid any text-heuristic split.
|
|
148
|
+
var thinkEl = null, answerEl = null, thinkTime = null;
|
|
149
|
+
if (${parseThinking} && bubbles.length > 0) {
|
|
150
|
+
var lastBubble = bubbles[bubbles.length - 1];
|
|
151
|
+
// Thinking container — DeepSeek uses various class names;
|
|
152
|
+
// try common selectors.
|
|
153
|
+
thinkEl = lastBubble.querySelector('.ds-markdown--think')
|
|
154
|
+
|| lastBubble.querySelector('[class*="think"]');
|
|
155
|
+
// Final answer container — the main markdown block that is
|
|
156
|
+
// NOT the thinking section.
|
|
157
|
+
var markdownEls = lastBubble.querySelectorAll('.ds-markdown');
|
|
158
|
+
for (var i = 0; i < markdownEls.length; i++) {
|
|
159
|
+
if (markdownEls[i] !== thinkEl
|
|
160
|
+
&& !(thinkEl && thinkEl.contains(markdownEls[i]))
|
|
161
|
+
&& !markdownEls[i].classList.contains('ds-markdown--think')) {
|
|
162
|
+
answerEl = markdownEls[i];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// Thinking time from the toggle/header element
|
|
166
|
+
var timeEl = lastBubble.querySelector('[class*="think"] ~ *')
|
|
167
|
+
|| lastBubble.querySelector('.ds-thinking-header');
|
|
168
|
+
if (!timeEl) {
|
|
169
|
+
// Fallback: parse from raw text header
|
|
170
|
+
var m = last.match(/^(?:Thought for ([\\d.]+) seconds?|已思考(用时 ([\\d.]+) 秒))/);
|
|
171
|
+
if (m) thinkTime = m[1] || m[2];
|
|
172
|
+
} else {
|
|
173
|
+
var tm = (timeEl.textContent || '').match(/([\\d.]+)/);
|
|
174
|
+
if (tm) thinkTime = tm[1];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
count: texts.length,
|
|
180
|
+
last: last,
|
|
181
|
+
// DOM-separated fields (null when not available)
|
|
182
|
+
thinkText: thinkEl ? (thinkEl.innerText || '').trim() : null,
|
|
183
|
+
answerText: answerEl ? (answerEl.innerText || '').trim() : null,
|
|
184
|
+
thinkTime: thinkTime,
|
|
185
|
+
};
|
|
118
186
|
})()`);
|
|
119
187
|
} catch {
|
|
120
188
|
continue;
|
|
@@ -126,7 +194,21 @@ export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
|
|
|
126
194
|
if (candidate && result.count > baselineCount && candidate !== prompt.trim()) {
|
|
127
195
|
if (candidate === lastText) {
|
|
128
196
|
stableCount++;
|
|
129
|
-
if (stableCount >= 3)
|
|
197
|
+
if (stableCount >= 3) {
|
|
198
|
+
if (parseThinking) {
|
|
199
|
+
// Prefer DOM-level separation
|
|
200
|
+
if (result.thinkText != null || result.answerText != null) {
|
|
201
|
+
return {
|
|
202
|
+
thinking: result.thinkText || '',
|
|
203
|
+
response: result.answerText || '',
|
|
204
|
+
thinking_time: result.thinkTime || null,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// Fallback to text-header parsing (no \n\n split)
|
|
208
|
+
return parseThinkingResponse(candidate);
|
|
209
|
+
}
|
|
210
|
+
return candidate;
|
|
211
|
+
}
|
|
130
212
|
} else {
|
|
131
213
|
stableCount = 0;
|
|
132
214
|
}
|
|
@@ -134,6 +216,9 @@ export async function waitForResponse(page, baselineCount, prompt, timeoutMs) {
|
|
|
134
216
|
}
|
|
135
217
|
}
|
|
136
218
|
|
|
219
|
+
if (parseThinking && lastText) {
|
|
220
|
+
return parseThinkingResponse(lastText);
|
|
221
|
+
}
|
|
137
222
|
return lastText || null;
|
|
138
223
|
}
|
|
139
224
|
|
|
@@ -167,8 +252,7 @@ export async function getConversationList(page) {
|
|
|
167
252
|
const items = [];
|
|
168
253
|
const links = document.querySelectorAll('a[href*="/a/chat/s/"]');
|
|
169
254
|
links.forEach((link, i) => {
|
|
170
|
-
const
|
|
171
|
-
const title = titleEl ? titleEl.textContent.trim() : '';
|
|
255
|
+
const title = (link.innerText || '').trim().split('\\n')[0].trim();
|
|
172
256
|
const href = link.getAttribute('href') || '';
|
|
173
257
|
const idMatch = href.match(/\\/s\\/([a-f0-9-]+)/);
|
|
174
258
|
items.push({
|
|
@@ -2,7 +2,90 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
import { sendWithFile } from './utils.js';
|
|
5
|
+
import { selectModel, sendWithFile, parseThinkingResponse } from './utils.js';
|
|
6
|
+
|
|
7
|
+
describe('deepseek parseThinkingResponse', () => {
|
|
8
|
+
it('returns plain response when no thinking header is present', () => {
|
|
9
|
+
const rawText = 'This is a regular response without thinking.';
|
|
10
|
+
const result = parseThinkingResponse(rawText);
|
|
11
|
+
|
|
12
|
+
expect(result).toEqual({
|
|
13
|
+
response: rawText,
|
|
14
|
+
thinking: null,
|
|
15
|
+
thinking_time: null,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('parses English thinking header — all content after header is thinking', () => {
|
|
20
|
+
const rawText = 'Thought for 3.5 seconds\n\nLet me analyze this problem...\nFirst, I need to consider X.\nThen, Y.\n\nThe answer is 42.';
|
|
21
|
+
const result = parseThinkingResponse(rawText);
|
|
22
|
+
|
|
23
|
+
// Text-level parser no longer splits on \n\n; everything after header is thinking.
|
|
24
|
+
// DOM-level extraction in waitForResponse() handles the actual separation.
|
|
25
|
+
expect(result).toEqual({
|
|
26
|
+
response: '',
|
|
27
|
+
thinking: 'Let me analyze this problem...\nFirst, I need to consider X.\nThen, Y.\n\nThe answer is 42.',
|
|
28
|
+
thinking_time: '3.5',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('parses Chinese thinking header — all content after header is thinking', () => {
|
|
33
|
+
const rawText = '已思考(用时 2.3 秒)\n\n让我分析这个问题...\n首先需要考虑X。\n然后是Y。\n\n答案是42。';
|
|
34
|
+
const result = parseThinkingResponse(rawText);
|
|
35
|
+
|
|
36
|
+
expect(result).toEqual({
|
|
37
|
+
response: '',
|
|
38
|
+
thinking: '让我分析这个问题...\n首先需要考虑X。\n然后是Y。\n\n答案是42。',
|
|
39
|
+
thinking_time: '2.3',
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('multi-paragraph thinking without final answer is not corrupted', () => {
|
|
44
|
+
const rawText = 'Thought for 1.2 seconds\n\nFirst paragraph.\n\nSecond paragraph.';
|
|
45
|
+
const result = parseThinkingResponse(rawText);
|
|
46
|
+
|
|
47
|
+
// Both paragraphs must stay in thinking; response is empty.
|
|
48
|
+
expect(result).toEqual({
|
|
49
|
+
response: '',
|
|
50
|
+
thinking: 'First paragraph.\n\nSecond paragraph.',
|
|
51
|
+
thinking_time: '1.2',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('multi-paragraph final answer is not split by text parser', () => {
|
|
56
|
+
const rawText = 'Thought for 3 seconds\n\nreasoning\n\nAnswer para 1.\n\nAnswer para 2.';
|
|
57
|
+
const result = parseThinkingResponse(rawText);
|
|
58
|
+
|
|
59
|
+
// Text parser treats everything as thinking; DOM handles separation.
|
|
60
|
+
expect(result).toEqual({
|
|
61
|
+
response: '',
|
|
62
|
+
thinking: 'reasoning\n\nAnswer para 1.\n\nAnswer para 2.',
|
|
63
|
+
thinking_time: '3',
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('handles thinking without final response', () => {
|
|
68
|
+
const rawText = 'Thought for 1.2 seconds\n\nThinking process here...';
|
|
69
|
+
const result = parseThinkingResponse(rawText);
|
|
70
|
+
|
|
71
|
+
expect(result).toEqual({
|
|
72
|
+
response: '',
|
|
73
|
+
thinking: 'Thinking process here...',
|
|
74
|
+
thinking_time: '1.2',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns null for empty input', () => {
|
|
79
|
+
const result = parseThinkingResponse('');
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns null for null input', () => {
|
|
84
|
+
const result = parseThinkingResponse(null);
|
|
85
|
+
expect(result).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
6
89
|
|
|
7
90
|
describe('deepseek sendWithFile', () => {
|
|
8
91
|
const tempDirs = [];
|
|
@@ -35,3 +118,28 @@ describe('deepseek sendWithFile', () => {
|
|
|
35
118
|
expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
|
|
36
119
|
});
|
|
37
120
|
});
|
|
121
|
+
|
|
122
|
+
describe('deepseek selectModel', () => {
|
|
123
|
+
afterEach(() => {
|
|
124
|
+
vi.restoreAllMocks();
|
|
125
|
+
delete global.document;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('fails expert selection when only one radio is present', async () => {
|
|
129
|
+
const instantRadio = {
|
|
130
|
+
getAttribute: vi.fn(() => 'true'),
|
|
131
|
+
click: vi.fn(),
|
|
132
|
+
};
|
|
133
|
+
global.document = {
|
|
134
|
+
querySelectorAll: vi.fn(() => [instantRadio]),
|
|
135
|
+
};
|
|
136
|
+
const page = {
|
|
137
|
+
evaluate: vi.fn(async (script) => eval(script)),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const result = await selectModel(page, 'expert');
|
|
141
|
+
|
|
142
|
+
expect(result).toEqual({ ok: false });
|
|
143
|
+
expect(instantRadio.click).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
});
|
package/clis/gemini/image.js
CHANGED
|
@@ -57,7 +57,7 @@ export const imageCommand = cli({
|
|
|
57
57
|
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to Gemini' },
|
|
58
58
|
{ name: 'rt', default: '1:1', help: 'Ratio shorthand for aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3)' },
|
|
59
59
|
{ name: 'st', default: '', help: 'Style shorthand, e.g. anime, icon, watercolor' },
|
|
60
|
-
{ name: 'op', default:
|
|
60
|
+
{ name: 'op', default: '~/tmp/gemini-images', help: 'Output directory shorthand' },
|
|
61
61
|
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show Gemini page link' },
|
|
62
62
|
],
|
|
63
63
|
columns: ['status', 'file', 'link'],
|
|
@@ -202,7 +202,7 @@ cli({
|
|
|
202
202
|
navigateBefore: false,
|
|
203
203
|
args: [
|
|
204
204
|
{ name: 'url', positional: true, required: true, help: 'Instagram post / reel / tv URL' },
|
|
205
|
-
{ name: 'path', default:
|
|
205
|
+
{ name: 'path', default: '~/Downloads/Instagram', help: 'Download directory' },
|
|
206
206
|
],
|
|
207
207
|
func: async (page, kwargs) => {
|
|
208
208
|
const browserPage = ensurePage(page);
|