@jackwener/opencli 1.7.16 → 1.7.17

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.
Files changed (149) hide show
  1. package/README.md +8 -9
  2. package/README.zh-CN.md +8 -8
  3. package/cli-manifest.json +97 -271
  4. package/clis/chatgpt/ask.js +1 -1
  5. package/clis/chatgpt/commands.test.js +2 -2
  6. package/clis/chatgpt/detail.js +1 -1
  7. package/clis/chatgpt/history.js +1 -1
  8. package/clis/chatgpt/image.js +38 -4
  9. package/clis/chatgpt/image.test.js +68 -1
  10. package/clis/chatgpt/new.js +1 -1
  11. package/clis/chatgpt/read.js +1 -1
  12. package/clis/chatgpt/send.js +1 -1
  13. package/clis/chatgpt/status.js +1 -1
  14. package/clis/chatgpt/utils.js +208 -16
  15. package/clis/chatgpt/utils.test.js +131 -2
  16. package/clis/claude/ask.js +1 -1
  17. package/clis/claude/detail.js +1 -1
  18. package/clis/claude/history.js +1 -1
  19. package/clis/claude/new.js +1 -1
  20. package/clis/claude/read.js +1 -1
  21. package/clis/claude/send.js +1 -1
  22. package/clis/claude/status.js +1 -1
  23. package/clis/deepseek/ask.js +1 -1
  24. package/clis/deepseek/detail.js +1 -1
  25. package/clis/deepseek/history.js +1 -1
  26. package/clis/deepseek/new.js +1 -1
  27. package/clis/deepseek/read.js +1 -1
  28. package/clis/deepseek/send.js +1 -1
  29. package/clis/deepseek/status.js +1 -1
  30. package/clis/doubao/ask.js +1 -1
  31. package/clis/doubao/detail.js +1 -1
  32. package/clis/doubao/history.js +1 -1
  33. package/clis/doubao/meeting-summary.js +1 -1
  34. package/clis/doubao/meeting-transcript.js +1 -1
  35. package/clis/doubao/new.js +1 -1
  36. package/clis/doubao/read.js +1 -1
  37. package/clis/doubao/send.js +1 -1
  38. package/clis/doubao/status.js +1 -1
  39. package/clis/gemini/ask.js +1 -1
  40. package/clis/gemini/deep-research-result.js +1 -1
  41. package/clis/gemini/deep-research.js +1 -1
  42. package/clis/gemini/image.js +1 -1
  43. package/clis/gemini/new.js +1 -1
  44. package/clis/grok/ask.js +1 -1
  45. package/clis/grok/detail.js +1 -1
  46. package/clis/grok/history.js +1 -1
  47. package/clis/grok/image.js +1 -1
  48. package/clis/grok/new.js +1 -1
  49. package/clis/grok/read.js +1 -1
  50. package/clis/grok/send.js +1 -1
  51. package/clis/grok/status.js +1 -1
  52. package/clis/notebooklm/current.js +1 -1
  53. package/clis/notebooklm/get.js +1 -1
  54. package/clis/notebooklm/history.js +1 -1
  55. package/clis/notebooklm/note-list.js +1 -1
  56. package/clis/notebooklm/notes-get.js +1 -1
  57. package/clis/notebooklm/open.js +2 -2
  58. package/clis/notebooklm/open.test.js +1 -1
  59. package/clis/notebooklm/source-fulltext.js +1 -1
  60. package/clis/notebooklm/source-get.js +1 -1
  61. package/clis/notebooklm/source-guide.js +1 -1
  62. package/clis/notebooklm/source-list.js +1 -1
  63. package/clis/notebooklm/summary.js +1 -1
  64. package/clis/qwen/ask.js +1 -1
  65. package/clis/qwen/detail.js +1 -1
  66. package/clis/qwen/history.js +1 -1
  67. package/clis/qwen/image.js +1 -1
  68. package/clis/qwen/new.js +1 -1
  69. package/clis/qwen/read.js +1 -1
  70. package/clis/qwen/send.js +1 -1
  71. package/clis/qwen/status.js +1 -1
  72. package/clis/reddit/comment.js +1 -1
  73. package/clis/reddit/frontpage.js +1 -1
  74. package/clis/reddit/popular.js +1 -1
  75. package/clis/reddit/read.js +1 -1
  76. package/clis/reddit/read.test.js +2 -2
  77. package/clis/reddit/save.js +1 -1
  78. package/clis/reddit/saved.js +1 -1
  79. package/clis/reddit/search.js +1 -1
  80. package/clis/reddit/subreddit.js +1 -1
  81. package/clis/reddit/subscribe.js +1 -1
  82. package/clis/reddit/upvote.js +1 -1
  83. package/clis/reddit/upvoted.js +1 -1
  84. package/clis/reddit/user-comments.js +1 -1
  85. package/clis/reddit/user-posts.js +1 -1
  86. package/clis/reddit/user.js +1 -1
  87. package/clis/twitter/article.js +1 -1
  88. package/clis/twitter/bookmark-folder.js +1 -1
  89. package/clis/twitter/bookmark-folders.js +1 -1
  90. package/clis/twitter/bookmarks.js +1 -1
  91. package/clis/twitter/download.js +1 -1
  92. package/clis/twitter/followers.js +1 -1
  93. package/clis/twitter/following.js +1 -1
  94. package/clis/twitter/likes.js +1 -1
  95. package/clis/twitter/list-tweets.js +1 -1
  96. package/clis/twitter/lists.js +1 -1
  97. package/clis/twitter/notifications.js +1 -1
  98. package/clis/twitter/profile.js +1 -1
  99. package/clis/twitter/search.js +1 -1
  100. package/clis/twitter/thread.js +1 -1
  101. package/clis/twitter/timeline.js +1 -1
  102. package/clis/twitter/trending.js +1 -1
  103. package/clis/twitter/tweets.js +1 -1
  104. package/clis/yuanbao/ask.js +1 -1
  105. package/clis/yuanbao/detail.js +1 -1
  106. package/clis/yuanbao/history.js +1 -1
  107. package/clis/yuanbao/new.js +1 -1
  108. package/clis/yuanbao/read.js +1 -1
  109. package/clis/yuanbao/send.js +1 -1
  110. package/clis/yuanbao/status.js +1 -1
  111. package/dist/src/browser/bridge.d.ts +3 -1
  112. package/dist/src/browser/bridge.js +3 -1
  113. package/dist/src/browser/cdp.d.ts +3 -1
  114. package/dist/src/browser/daemon-client.d.ts +7 -14
  115. package/dist/src/browser/daemon-client.js +2 -6
  116. package/dist/src/browser/network-cache.d.ts +5 -5
  117. package/dist/src/browser/network-cache.js +8 -8
  118. package/dist/src/browser/network-cache.test.js +4 -4
  119. package/dist/src/browser/page.d.ts +8 -7
  120. package/dist/src/browser/page.js +23 -16
  121. package/dist/src/browser/page.test.js +60 -30
  122. package/dist/src/build-manifest.js +1 -1
  123. package/dist/src/cli.js +60 -162
  124. package/dist/src/cli.test.js +178 -197
  125. package/dist/src/commanderAdapter.js +2 -0
  126. package/dist/src/discovery.js +1 -1
  127. package/dist/src/doctor.d.ts +0 -4
  128. package/dist/src/doctor.js +8 -72
  129. package/dist/src/doctor.test.js +26 -97
  130. package/dist/src/execution.d.ts +1 -0
  131. package/dist/src/execution.js +20 -21
  132. package/dist/src/execution.test.js +27 -31
  133. package/dist/src/help.js +7 -1
  134. package/dist/src/main.js +0 -19
  135. package/dist/src/manifest-types.d.ts +2 -4
  136. package/dist/src/observation/artifact.js +1 -1
  137. package/dist/src/observation/artifact.test.js +3 -3
  138. package/dist/src/observation/events.d.ts +1 -1
  139. package/dist/src/observation/manager.js +1 -1
  140. package/dist/src/observation/manager.test.js +3 -3
  141. package/dist/src/registry-api.d.ts +1 -1
  142. package/dist/src/registry.d.ts +3 -12
  143. package/dist/src/registry.js +6 -10
  144. package/dist/src/runtime.d.ts +7 -2
  145. package/dist/src/runtime.js +3 -1
  146. package/dist/src/serialization.d.ts +1 -1
  147. package/dist/src/serialization.js +1 -1
  148. package/dist/src/types.d.ts +0 -15
  149. package/package.json +1 -1
@@ -22,7 +22,7 @@ export const askCommand = cli({
22
22
  domain: CHATGPT_DOMAIN,
23
23
  strategy: Strategy.COOKIE,
24
24
  browser: true,
25
- browserSession: { reuse: 'site' },
25
+ siteSession: 'persistent',
26
26
  navigateBefore: false,
27
27
  args: [
28
28
  { name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
@@ -10,7 +10,7 @@ import './status.js';
10
10
  import './image.js';
11
11
 
12
12
  describe('chatgpt browser command registration', () => {
13
- it('registers the baseline web chat commands with site-level reuse', () => {
13
+ it('registers the baseline web chat commands with persistent site sessions', () => {
14
14
  const expectedAccess = {
15
15
  ask: 'write',
16
16
  send: 'write',
@@ -29,7 +29,7 @@ describe('chatgpt browser command registration', () => {
29
29
  expect(cmd.domain).toBe('chatgpt.com');
30
30
  expect(cmd.strategy).toBe('cookie');
31
31
  expect(cmd.browser).toBe(true);
32
- expect(cmd.browserSession).toEqual({ reuse: 'site' });
32
+ expect(cmd.siteSession).toBe('persistent');
33
33
  expect(cmd.navigateBefore).toBe(false);
34
34
  expect(cmd.access).toBe(access);
35
35
  }
@@ -19,7 +19,7 @@ export const detailCommand = cli({
19
19
  domain: CHATGPT_DOMAIN,
20
20
  strategy: Strategy.COOKIE,
21
21
  browser: true,
22
- browserSession: { reuse: 'site' },
22
+ siteSession: 'persistent',
23
23
  navigateBefore: false,
24
24
  args: [
25
25
  { name: 'id', positional: true, required: true, help: 'Conversation ID or full /c/<id> URL' },
@@ -16,7 +16,7 @@ export const historyCommand = cli({
16
16
  domain: CHATGPT_DOMAIN,
17
17
  strategy: Strategy.COOKIE,
18
18
  browser: true,
19
- browserSession: { reuse: 'site' },
19
+ siteSession: 'persistent',
20
20
  navigateBefore: false,
21
21
  args: [
22
22
  { name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' },
@@ -4,7 +4,7 @@ import * as fs from 'node:fs';
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
5
  import { saveBase64ToFile } from '@jackwener/opencli/utils';
6
6
  import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
7
- import { getChatGPTVisibleImageUrls, normalizeBooleanFlag, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets } from './utils.js';
7
+ import { clearChatGPTDraft, getChatGPTVisibleImageUrls, normalizeBooleanFlag, prepareChatGPTImagePaths, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js';
8
8
 
9
9
  const CHATGPT_DOMAIN = 'chatgpt.com';
10
10
 
@@ -36,6 +36,23 @@ export function nextAvailablePath(dir, baseName, ext, existsSync = fs.existsSync
36
36
  return candidate;
37
37
  }
38
38
 
39
+ export function parseImagePaths(value) {
40
+ if (Array.isArray(value)) {
41
+ return value.flatMap(item => parseImagePaths(item));
42
+ }
43
+ return String(value ?? '')
44
+ .split(',')
45
+ .map(item => item.trim())
46
+ .filter(Boolean);
47
+ }
48
+
49
+ function buildPrompt(prompt, imageCount) {
50
+ if (imageCount > 0) {
51
+ return `Edit the attached image${imageCount === 1 ? '' : 's'}: ${prompt}`;
52
+ }
53
+ return `Generate an image of: ${prompt}`;
54
+ }
55
+
39
56
  async function currentChatGPTLink(page) {
40
57
  const url = await page.evaluate('window.location.href').catch(() => '');
41
58
  return typeof url === 'string' && url ? url : 'https://chatgpt.com';
@@ -49,11 +66,12 @@ export const imageCommand = cli({
49
66
  domain: CHATGPT_DOMAIN,
50
67
  strategy: Strategy.COOKIE,
51
68
  browser: true,
52
- browserSession: { reuse: 'site' },
69
+ siteSession: 'persistent',
53
70
  navigateBefore: false,
54
71
  defaultFormat: 'plain',
55
72
  args: [
56
73
  { name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
74
+ { name: 'image', help: 'Local image path to attach before prompting; comma-separated paths are supported' },
57
75
  { name: 'op', help: 'Output directory (default: ~/Pictures/chatgpt)' },
58
76
  { name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
59
77
  { name: 'timeout', type: 'int', required: false, default: 240, help: 'Max seconds for the overall command (default: 240)' },
@@ -61,6 +79,7 @@ export const imageCommand = cli({
61
79
  columns: ['status', 'file', 'link'],
62
80
  func: async (page, kwargs) => {
63
81
  const prompt = kwargs.prompt;
82
+ const imagePaths = parseImagePaths(kwargs.image);
64
83
  const outputDir = resolveOutputDir(kwargs.op);
65
84
  const skipDownloadRaw = kwargs.sd;
66
85
  const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);
@@ -68,14 +87,29 @@ export const imageCommand = cli({
68
87
  if (!Number.isInteger(timeout) || timeout < 1) {
69
88
  throw new ArgumentError('--timeout must be a positive integer (seconds)');
70
89
  }
90
+ const preparedImages = imagePaths.length ? await prepareChatGPTImagePaths(imagePaths) : { ok: true, paths: [] };
91
+ if (!preparedImages.ok) {
92
+ throw new ArgumentError(preparedImages.reason);
93
+ }
71
94
 
72
95
  // Navigate to chatgpt.com/new with full reload to clear React sidebar state
73
96
  await page.goto(`https://${CHATGPT_DOMAIN}/new`, { settleMs: 2000 });
97
+ await clearChatGPTDraft(page);
98
+
99
+ if (imagePaths.length) {
100
+ let upload;
101
+ try {
102
+ upload = await uploadChatGPTImages(page, preparedImages.paths);
103
+ } catch (err) {
104
+ throw new CommandExecutionError(`Failed to upload image to ChatGPT: ${err instanceof Error ? err.message : String(err)}`);
105
+ }
106
+ if (!upload?.ok) throw new CommandExecutionError(upload?.reason || 'Failed to upload image to ChatGPT');
107
+ }
74
108
 
75
109
  const beforeUrls = await getChatGPTVisibleImageUrls(page);
76
110
 
77
- // Send the image generation prompt - must be explicit
78
- const sent = await sendChatGPTMessage(page, `Generate an image of: ${prompt}`);
111
+ // Send an explicit generation/editing prompt so ChatGPT returns image assets.
112
+ const sent = await sendChatGPTMessage(page, buildPrompt(prompt, imagePaths.length));
79
113
  if (!sent) {
80
114
  throw new CommandExecutionError(
81
115
  'Failed to send image prompt to ChatGPT',
@@ -4,13 +4,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
4
 
5
5
  const mocks = vi.hoisted(() => ({
6
6
  getChatGPTVisibleImageUrls: vi.fn(),
7
+ clearChatGPTDraft: vi.fn(),
8
+ prepareChatGPTImagePaths: vi.fn(),
7
9
  sendChatGPTMessage: vi.fn(),
10
+ uploadChatGPTImages: vi.fn(),
8
11
  waitForChatGPTImages: vi.fn(),
9
12
  getChatGPTImageAssets: vi.fn(),
10
13
  saveBase64ToFile: vi.fn(),
11
14
  }));
12
15
 
13
16
  vi.mock('./utils.js', () => ({
17
+ clearChatGPTDraft: mocks.clearChatGPTDraft,
14
18
  getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
15
19
  normalizeBooleanFlag: (value, fallback = false) => {
16
20
  if (typeof value === 'boolean') return value;
@@ -18,7 +22,9 @@ vi.mock('./utils.js', () => ({
18
22
  const normalized = String(value).trim().toLowerCase();
19
23
  return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
20
24
  },
25
+ prepareChatGPTImagePaths: mocks.prepareChatGPTImagePaths,
21
26
  sendChatGPTMessage: mocks.sendChatGPTMessage,
27
+ uploadChatGPTImages: mocks.uploadChatGPTImages,
22
28
  waitForChatGPTImages: mocks.waitForChatGPTImages,
23
29
  getChatGPTImageAssets: mocks.getChatGPTImageAssets,
24
30
  }));
@@ -27,7 +33,7 @@ vi.mock('@jackwener/opencli/utils', () => ({
27
33
  saveBase64ToFile: mocks.saveBase64ToFile,
28
34
  }));
29
35
 
30
- const { imageCommand, nextAvailablePath, resolveOutputDir } = await import('./image.js');
36
+ const { imageCommand, nextAvailablePath, parseImagePaths, resolveOutputDir } = await import('./image.js');
31
37
 
32
38
  function createPage() {
33
39
  return {
@@ -39,8 +45,11 @@ function createPage() {
39
45
 
40
46
  beforeEach(() => {
41
47
  vi.restoreAllMocks();
48
+ mocks.clearChatGPTDraft.mockReset().mockResolvedValue(undefined);
49
+ mocks.prepareChatGPTImagePaths.mockReset().mockImplementation(async (paths) => ({ ok: true, paths }));
42
50
  mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
43
51
  mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
52
+ mocks.uploadChatGPTImages.mockReset().mockResolvedValue({ ok: true });
44
53
  mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']);
45
54
  mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{
46
55
  url: 'https://images.example/generated.png',
@@ -66,6 +75,64 @@ describe('chatgpt image output paths', () => {
66
75
 
67
76
  expect(nextAvailablePath(dir, 'chatgpt_123', '.png', (file) => taken.has(file))).toBe(path.join(dir, 'chatgpt_123_2.png'));
68
77
  });
78
+
79
+ it('parses comma-separated image paths', () => {
80
+ expect(parseImagePaths('/tmp/a.png, /tmp/b.jpg')).toEqual(['/tmp/a.png', '/tmp/b.jpg']);
81
+ expect(parseImagePaths([' /tmp/a.png ', '/tmp/b.jpg,/tmp/c.webp'])).toEqual(['/tmp/a.png', '/tmp/b.jpg', '/tmp/c.webp']);
82
+ });
83
+ });
84
+
85
+ describe('chatgpt image upload flow', () => {
86
+ it('uploads local images before sending an edit prompt', async () => {
87
+ mocks.prepareChatGPTImagePaths.mockResolvedValue({ ok: true, paths: ['/abs/cat.png', '/abs/dog.jpg'] });
88
+ await imageCommand.func(createPage(), {
89
+ prompt: 'make the background blue',
90
+ image: '/tmp/cat.png,/tmp/dog.jpg',
91
+ op: '',
92
+ sd: true,
93
+ timeout: 240,
94
+ });
95
+
96
+ expect(mocks.clearChatGPTDraft).toHaveBeenCalled();
97
+ expect(mocks.uploadChatGPTImages).toHaveBeenCalledWith(expect.anything(), ['/abs/cat.png', '/abs/dog.jpg']);
98
+ expect(mocks.uploadChatGPTImages.mock.invocationCallOrder[0]).toBeLessThan(
99
+ mocks.getChatGPTVisibleImageUrls.mock.invocationCallOrder[0],
100
+ );
101
+ expect(mocks.sendChatGPTMessage).toHaveBeenCalledWith(expect.anything(), 'Edit the attached images: make the background blue');
102
+ });
103
+
104
+ it('rejects invalid local image paths before browser navigation', async () => {
105
+ mocks.prepareChatGPTImagePaths.mockResolvedValue({ ok: false, reason: 'Image not found: /tmp/missing.png' });
106
+ const page = createPage();
107
+
108
+ await expect(imageCommand.func(page, {
109
+ prompt: 'make the background blue',
110
+ image: '/tmp/missing.png',
111
+ op: '',
112
+ sd: false,
113
+ timeout: 240,
114
+ })).rejects.toMatchObject({
115
+ code: 'ARGUMENT',
116
+ message: expect.stringContaining('Image not found'),
117
+ });
118
+ expect(page.goto).not.toHaveBeenCalled();
119
+ expect(mocks.uploadChatGPTImages).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('surfaces upload failures as command execution errors', async () => {
123
+ mocks.uploadChatGPTImages.mockResolvedValue({ ok: false, reason: 'image upload preview did not appear' });
124
+
125
+ await expect(imageCommand.func(createPage(), {
126
+ prompt: 'make the background blue',
127
+ image: '/tmp/cat.png',
128
+ op: '',
129
+ sd: false,
130
+ timeout: 240,
131
+ })).rejects.toMatchObject({
132
+ code: 'COMMAND_EXEC',
133
+ message: expect.stringContaining('image upload preview did not appear'),
134
+ });
135
+ });
69
136
  });
70
137
 
71
138
  describe('chatgpt image failure contracts', () => {
@@ -13,7 +13,7 @@ export const newCommand = cli({
13
13
  domain: CHATGPT_DOMAIN,
14
14
  strategy: Strategy.COOKIE,
15
15
  browser: true,
16
- browserSession: { reuse: 'site' },
16
+ siteSession: 'persistent',
17
17
  navigateBefore: false,
18
18
  args: [],
19
19
  columns: ['Status'],
@@ -17,7 +17,7 @@ export const readCommand = cli({
17
17
  domain: CHATGPT_DOMAIN,
18
18
  strategy: Strategy.COOKIE,
19
19
  browser: true,
20
- browserSession: { reuse: 'site' },
20
+ siteSession: 'persistent',
21
21
  navigateBefore: false,
22
22
  args: [
23
23
  { name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' },
@@ -19,7 +19,7 @@ export const sendCommand = cli({
19
19
  domain: CHATGPT_DOMAIN,
20
20
  strategy: Strategy.COOKIE,
21
21
  browser: true,
22
- browserSession: { reuse: 'site' },
22
+ siteSession: 'persistent',
23
23
  navigateBefore: false,
24
24
  args: [
25
25
  { name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
@@ -13,7 +13,7 @@ export const statusCommand = cli({
13
13
  domain: CHATGPT_DOMAIN,
14
14
  strategy: Strategy.COOKIE,
15
15
  browser: true,
16
- browserSession: { reuse: 'site' },
16
+ siteSession: 'persistent',
17
17
  navigateBefore: false,
18
18
  args: [],
19
19
  columns: ['Status', 'Login', 'Url'],
@@ -20,6 +20,9 @@ const COMPOSER_SELECTORS = [
20
20
  '[contenteditable="true"][role="textbox"]',
21
21
  ];
22
22
  const SEND_BUTTON_SELECTOR = 'button[data-testid="send-button"]:not([disabled])';
23
+ const SEND_BUTTON_FALLBACK_SELECTORS = [
24
+ '#composer-submit-button:not([disabled])',
25
+ ];
23
26
  const SEND_BUTTON_LABELS = [
24
27
  'Send prompt',
25
28
  'Send message',
@@ -203,6 +206,40 @@ export async function ensureChatGPTComposer(page, message = 'ChatGPT composer is
203
206
  return state;
204
207
  }
205
208
 
209
+ export async function clearChatGPTDraft(page) {
210
+ await page.evaluate(`
211
+ (() => {
212
+ const removeLabels = [/^remove file/i, /^移除文件/];
213
+ for (let pass = 0; pass < 10; pass += 1) {
214
+ const button = Array.from(document.querySelectorAll('button')).find((node) => {
215
+ const label = node.getAttribute('aria-label') || '';
216
+ return removeLabels.some((pattern) => pattern.test(label));
217
+ });
218
+ if (!button) break;
219
+ button.click();
220
+ }
221
+
222
+ const selectors = ${JSON.stringify(COMPOSER_SELECTORS)};
223
+ for (const selector of selectors) {
224
+ for (const node of document.querySelectorAll(selector)) {
225
+ if (!(node instanceof HTMLElement)) continue;
226
+ if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) {
227
+ node.value = '';
228
+ } else if (node.isContentEditable) {
229
+ node.textContent = '';
230
+ node.innerHTML = '<p><br></p>';
231
+ } else {
232
+ node.textContent = '';
233
+ }
234
+ node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
235
+ node.dispatchEvent(new Event('change', { bubbles: true }));
236
+ }
237
+ }
238
+ })()
239
+ `);
240
+ await page.wait(0.5);
241
+ }
242
+
206
243
  /**
207
244
  * Send a message to the ChatGPT composer and submit it.
208
245
  * Returns true if the message was sent successfully.
@@ -227,7 +264,16 @@ export async function sendChatGPTMessage(page, text) {
227
264
  const composer = findComposer();
228
265
  if (!composer) return false;
229
266
  composer.focus();
230
- composer.textContent = '';
267
+ if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
268
+ composer.value = '';
269
+ } else if (composer.isContentEditable) {
270
+ composer.textContent = '';
271
+ composer.innerHTML = '<p><br></p>';
272
+ } else {
273
+ composer.textContent = '';
274
+ }
275
+ composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
276
+ composer.dispatchEvent(new Event('change', { bubbles: true }));
231
277
  return true;
232
278
  })()
233
279
  `);
@@ -255,27 +301,35 @@ export async function sendChatGPTMessage(page, text) {
255
301
  `);
256
302
  }
257
303
 
258
- // Wait for send button to appear (it only shows when there's text)
259
- await page.wait(1.5);
304
+ let sent = null;
305
+ for (let attempt = 0; attempt < 20; attempt += 1) {
306
+ await page.wait(0.5);
307
+ sent = await page.evaluate(`
308
+ (() => {
309
+ const isUsable = (button) => button
310
+ && !button.disabled
311
+ && button.getAttribute('aria-disabled') !== 'true';
312
+ const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
313
+ || ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
314
+ const btns = Array.from(document.querySelectorAll('button'));
315
+ const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
316
+ const sendBtn = isUsable(primary)
317
+ ? primary
318
+ : btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && isUsable(b));
319
+ return { sendBtnFound: !!sendBtn };
320
+ })()
321
+ `);
322
+ if (sent?.sendBtnFound) break;
323
+ }
260
324
 
261
- // Click send button
262
- const sent = await page.evaluate(`
263
- (() => {
264
- const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)});
265
- const btns = Array.from(document.querySelectorAll('button'));
266
- const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
267
- const sendBtn = primary || btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
268
- return { sendBtnFound: !!sendBtn };
269
- })()
270
- `);
271
-
272
- if (!sent || !sent.sendBtnFound) {
325
+ if (!sent?.sendBtnFound) {
273
326
  return false;
274
327
  }
275
328
 
276
329
  await page.evaluate(`
277
330
  (() => {
278
- const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)});
331
+ const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
332
+ || ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
279
333
  const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
280
334
  const sendBtn = primary || Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
281
335
  if (sendBtn) sendBtn.click();
@@ -462,6 +516,142 @@ async function extractConversationLinks(page) {
462
516
  : [];
463
517
  }
464
518
 
519
+ function imageMimeFromPath(filePath) {
520
+ const lower = String(filePath || '').toLowerCase();
521
+ if (lower.endsWith('.png')) return 'image/png';
522
+ if (lower.endsWith('.webp')) return 'image/webp';
523
+ if (lower.endsWith('.gif')) return 'image/gif';
524
+ if (lower.endsWith('.heic')) return 'image/heic';
525
+ if (lower.endsWith('.heif')) return 'image/heif';
526
+ return 'image/jpeg';
527
+ }
528
+
529
+ export async function prepareChatGPTImagePaths(imagePaths) {
530
+ const fs = await import('node:fs');
531
+ const path = await import('node:path');
532
+ const absPaths = imagePaths.map(filePath => path.default.resolve(filePath));
533
+ const allowedExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']);
534
+
535
+ for (const absPath of absPaths) {
536
+ if (!fs.default.existsSync(absPath)) {
537
+ return { ok: false, reason: `Image not found: ${absPath}` };
538
+ }
539
+ const stat = fs.default.statSync(absPath);
540
+ if (!stat.isFile()) {
541
+ return { ok: false, reason: `Not a file: ${absPath}` };
542
+ }
543
+ if (stat.size > 25 * 1024 * 1024) {
544
+ return { ok: false, reason: `Image too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Max: 25 MB` };
545
+ }
546
+ const ext = path.default.extname(absPath).toLowerCase();
547
+ if (!allowedExts.has(ext)) {
548
+ return { ok: false, reason: `Unsupported image type: ${absPath}` };
549
+ }
550
+ }
551
+
552
+ return { ok: true, paths: absPaths };
553
+ }
554
+
555
+ async function waitForChatGPTUploadPreview(page, fileNames) {
556
+ const namesJson = JSON.stringify(fileNames);
557
+ for (let attempt = 0; attempt < 10; attempt += 1) {
558
+ await page.wait(1);
559
+ const ready = await page.evaluate(`
560
+ (() => {
561
+ const names = ${namesJson};
562
+ const text = document.body ? (document.body.innerText || '') : '';
563
+ const matchedNames = names.filter(name => text.includes(name)).length;
564
+ if (matchedNames >= names.length) return true;
565
+
566
+ const composer = document.querySelector('[aria-label="Chat with ChatGPT"], [placeholder="Ask anything"], #prompt-textarea');
567
+ let root = composer;
568
+ for (let i = 0; i < 6 && root && root.parentElement; i += 1) root = root.parentElement;
569
+ const scope = root || document.body;
570
+ if (!scope) return false;
571
+
572
+ const previewNodes = scope.querySelectorAll('img[src], canvas, video, [style*="background-image"], [data-testid*="attachment"], [data-testid*="upload"], [class*="attachment"], [class*="upload"]');
573
+ return previewNodes.length >= names.length;
574
+ })()
575
+ `);
576
+ if (ready) return true;
577
+ }
578
+ return false;
579
+ }
580
+
581
+ export async function uploadChatGPTImages(page, imagePaths) {
582
+ const fs = await import('node:fs');
583
+ const path = await import('node:path');
584
+ const prepared = await prepareChatGPTImagePaths(imagePaths);
585
+ if (!prepared.ok) return prepared;
586
+ const absPaths = prepared.paths;
587
+
588
+ const fileNames = absPaths.map(filePath => path.default.basename(filePath));
589
+
590
+ let uploaded = false;
591
+ if (page.setFileInput) {
592
+ try {
593
+ await page.setFileInput(absPaths, 'input[type="file"]');
594
+ uploaded = true;
595
+ } catch (err) {
596
+ const msg = String(err?.message || err);
597
+ if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed') && !msg.includes('No element found')) {
598
+ throw err;
599
+ }
600
+ }
601
+ }
602
+
603
+ if (!uploaded) {
604
+ const files = absPaths.map(absPath => ({
605
+ name: path.default.basename(absPath),
606
+ mime: imageMimeFromPath(absPath),
607
+ base64: fs.default.readFileSync(absPath).toString('base64'),
608
+ }));
609
+ const fallbackResult = await page.evaluate(`
610
+ (() => {
611
+ const files = ${JSON.stringify(files)};
612
+ const input = document.querySelector('input[type="file"]');
613
+ if (!(input instanceof HTMLInputElement)) {
614
+ return { ok: false, reason: 'file input not found' };
615
+ }
616
+
617
+ const dt = new DataTransfer();
618
+ for (const item of files) {
619
+ const binary = atob(item.base64);
620
+ const bytes = new Uint8Array(binary.length);
621
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
622
+ dt.items.add(new File([bytes], item.name, { type: item.mime }));
623
+ }
624
+ input.files = dt.files;
625
+
626
+ const propsKey = Object.keys(input).find(key => key.startsWith('__reactProps$'));
627
+ if (propsKey && input[propsKey] && typeof input[propsKey].onChange === 'function') {
628
+ const nativeEvent = new Event('change', { bubbles: true });
629
+ input[propsKey].onChange({
630
+ target: input,
631
+ currentTarget: input,
632
+ nativeEvent,
633
+ preventDefault() {},
634
+ stopPropagation() {},
635
+ isDefaultPrevented() { return false; },
636
+ isPropagationStopped() { return false; },
637
+ persist() {},
638
+ });
639
+ } else {
640
+ input.dispatchEvent(new Event('input', { bubbles: true }));
641
+ input.dispatchEvent(new Event('change', { bubbles: true }));
642
+ }
643
+ return { ok: true };
644
+ })()
645
+ `);
646
+ if (fallbackResult && !fallbackResult.ok) return fallbackResult;
647
+ }
648
+
649
+ const ready = await waitForChatGPTUploadPreview(page, fileNames);
650
+ if (!ready) return { ok: false, reason: 'image upload preview did not appear' };
651
+
652
+ return { ok: true, files: absPaths };
653
+ }
654
+
465
655
  /**
466
656
  * Check if ChatGPT is still generating a response.
467
657
  */
@@ -573,10 +763,12 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, con
573
763
  export const __test__ = {
574
764
  COMPOSER_SELECTORS,
575
765
  SEND_BUTTON_SELECTOR,
766
+ SEND_BUTTON_FALLBACK_SELECTORS,
576
767
  SEND_BUTTON_LABELS,
577
768
  CLOSE_SIDEBAR_LABELS,
578
769
  isSameChatGPTConversation,
579
770
  parseChatGPTConversationId,
771
+ imageMimeFromPath,
580
772
  };
581
773
 
582
774
  /**