wcgw 1.3.0__py3-none-any.whl → 1.5.0__py3-none-any.whl

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.

Potentially problematic release.


This version of wcgw might be problematic. Click here for more details.

wcgw/client/tools.py CHANGED
@@ -11,6 +11,7 @@ import threading
11
11
  import importlib.metadata
12
12
  import time
13
13
  import traceback
14
+ from tempfile import TemporaryDirectory
14
15
  from typing import (
15
16
  Callable,
16
17
  Literal,
@@ -24,6 +25,7 @@ from typing import (
24
25
  import uuid
25
26
  from pydantic import BaseModel, TypeAdapter
26
27
  import typer
28
+ from .computer_use import run_computer_tool
27
29
  from websockets.sync.client import connect as syncconnect
28
30
 
29
31
  import os
@@ -57,11 +59,14 @@ from ..types_ import (
57
59
  ReadFile,
58
60
  ReadImage,
59
61
  ResetShell,
60
- Writefile,
62
+ Mouse,
63
+ Keyboard,
64
+ ScreenShot,
65
+ GetScreenInfo,
61
66
  )
62
67
 
63
68
  from .common import CostData, Models, discard_input
64
-
69
+ from .sys_utils import command_run
65
70
  from .openai_utils import get_input_cost, get_output_cost
66
71
 
67
72
  console = rich.console.Console(style="magenta", highlight=False, markup=False)
@@ -96,7 +101,8 @@ def ask_confirmation(prompt: Confirmation) -> str:
96
101
  return "Yes" if response.lower() == "y" else "No"
97
102
 
98
103
 
99
- PROMPT = "#@@"
104
+ PROMPT_CONST = "#@wcgw@#"
105
+ PROMPT = PROMPT_CONST
100
106
 
101
107
 
102
108
  def start_shell() -> pexpect.spawn: # type: ignore
@@ -125,7 +131,7 @@ def _is_int(mystr: str) -> bool:
125
131
 
126
132
 
127
133
  def _get_exit_code() -> int:
128
- if PROMPT != "#@@":
134
+ if PROMPT != PROMPT_CONST:
129
135
  return 0
130
136
  # First reset the prompt in case venv was sourced or other reasons.
131
137
  SHELL.sendline(f"export PS1={PROMPT}")
@@ -153,6 +159,7 @@ def _get_exit_code() -> int:
153
159
 
154
160
  BASH_CLF_OUTPUT = Literal["repl", "pending"]
155
161
  BASH_STATE: BASH_CLF_OUTPUT = "repl"
162
+ IS_IN_DOCKER: Optional[str] = ""
156
163
  CWD = os.getcwd()
157
164
 
158
165
 
@@ -167,10 +174,11 @@ Current working directory: {CWD}
167
174
 
168
175
 
169
176
  def reset_shell() -> str:
170
- global SHELL, BASH_STATE, CWD
177
+ global SHELL, BASH_STATE, CWD, IS_IN_DOCKER
171
178
  SHELL.close(True)
172
179
  SHELL = start_shell()
173
180
  BASH_STATE = "repl"
181
+ IS_IN_DOCKER = ""
174
182
  CWD = os.getcwd()
175
183
  return "Reset successful" + get_status()
176
184
 
@@ -234,6 +242,7 @@ def execute_bash(
234
242
  enc: tiktoken.Encoding,
235
243
  bash_arg: BashCommand | BashInteraction,
236
244
  max_tokens: Optional[int],
245
+ timeout_s: Optional[float],
237
246
  ) -> tuple[str, float]:
238
247
  global SHELL, BASH_STATE, CWD
239
248
  try:
@@ -333,7 +342,7 @@ def execute_bash(
333
342
  SHELL.expect(PROMPT)
334
343
  return "---\n\nFailure: user interrupted the execution", 0.0
335
344
 
336
- wait = 5
345
+ wait = timeout_s or 5
337
346
  index = SHELL.expect([PROMPT, pexpect.TIMEOUT], timeout=wait)
338
347
  if index == 1:
339
348
  BASH_STATE = "pending"
@@ -438,35 +447,70 @@ def read_image_from_shell(file_path: str) -> ImageData:
438
447
  if not os.path.isabs(file_path):
439
448
  file_path = os.path.join(CWD, file_path)
440
449
 
441
- if not os.path.exists(file_path):
442
- raise ValueError(f"File {file_path} does not exist")
443
-
444
- with open(file_path, "rb") as image_file:
445
- image_bytes = image_file.read()
446
- image_b64 = base64.b64encode(image_bytes).decode("utf-8")
447
- image_type = mimetypes.guess_type(file_path)[0]
448
- return ImageData(media_type=image_type, data=image_b64) # type: ignore
449
-
450
+ if not IS_IN_DOCKER:
451
+ if not os.path.exists(file_path):
452
+ raise ValueError(f"File {file_path} does not exist")
450
453
 
451
- def write_file(writefile: Writefile | CreateFileNew, error_on_exist: bool) -> str:
454
+ with open(file_path, "rb") as image_file:
455
+ image_bytes = image_file.read()
456
+ image_b64 = base64.b64encode(image_bytes).decode("utf-8")
457
+ image_type = mimetypes.guess_type(file_path)[0]
458
+ return ImageData(media_type=image_type, data=image_b64) # type: ignore
459
+ else:
460
+ with TemporaryDirectory() as tmpdir:
461
+ rcode = os.system(f"docker cp {IS_IN_DOCKER}:{file_path} {tmpdir}")
462
+ if rcode != 0:
463
+ raise Exception(f"Error: Read failed with code {rcode}")
464
+ path_ = os.path.join(tmpdir, os.path.basename(file_path))
465
+ with open(path_, "rb") as f:
466
+ image_bytes = f.read()
467
+ image_b64 = base64.b64encode(image_bytes).decode("utf-8")
468
+ image_type = mimetypes.guess_type(file_path)[0]
469
+ return ImageData(media_type=image_type, data=image_b64) # type: ignore
470
+
471
+
472
+ def write_file(writefile: CreateFileNew, error_on_exist: bool) -> str:
452
473
  if not os.path.isabs(writefile.file_path):
453
474
  return f"Failure: file_path should be absolute path, current working directory is {CWD}"
454
475
  else:
455
476
  path_ = writefile.file_path
456
477
 
457
- if error_on_exist and os.path.exists(path_):
458
- file_data = Path(path_).read_text()
459
- if file_data:
460
- return f"Error: can't write to existing file {path_}, use other functions to edit the file"
478
+ if not IS_IN_DOCKER:
479
+ if error_on_exist and os.path.exists(path_):
480
+ file_data = Path(path_).read_text()
481
+ if file_data:
482
+ return f"Error: can't write to existing file {path_}, use other functions to edit the file"
461
483
 
462
- path = Path(path_)
463
- path.parent.mkdir(parents=True, exist_ok=True)
484
+ path = Path(path_)
485
+ path.parent.mkdir(parents=True, exist_ok=True)
486
+
487
+ try:
488
+ with path.open("w") as f:
489
+ f.write(writefile.file_content)
490
+ except OSError as e:
491
+ return f"Error: {e}"
492
+ else:
493
+ if error_on_exist:
494
+ # Check if it exists using os.system
495
+ cmd = f"test -f {path_}"
496
+ status = os.system(f'docker exec {IS_IN_DOCKER} bash -c "{cmd}"')
497
+ if status == 0:
498
+ return f"Error: can't write to existing file {path_}, use other functions to edit the file"
499
+
500
+ with TemporaryDirectory() as tmpdir:
501
+ tmppath = os.path.join(tmpdir, os.path.basename(path_))
502
+ with open(tmppath, "w") as f:
503
+ f.write(writefile.file_content)
504
+ os.chmod(tmppath, 0o777)
505
+ parent_dir = os.path.dirname(path_)
506
+ rcode = os.system(f"docker exec {IS_IN_DOCKER} mkdir -p {parent_dir}")
507
+ if rcode != 0:
508
+ return f"Error: Write failed with code while creating dirs {rcode}"
509
+
510
+ rcode = os.system(f"docker cp {tmppath} {IS_IN_DOCKER}:{path_}")
511
+ if rcode != 0:
512
+ return f"Error: Write failed with code {rcode}"
464
513
 
465
- try:
466
- with path.open("w") as f:
467
- f.write(writefile.file_content)
468
- except OSError as e:
469
- return f"Error: {e}"
470
514
  console.print(f"File written to {path_}")
471
515
  return "Success"
472
516
 
@@ -545,11 +589,21 @@ def do_diff_edit(fedit: FileEdit) -> str:
545
589
  else:
546
590
  path_ = fedit.file_path
547
591
 
548
- if not os.path.exists(path_):
549
- raise Exception(f"Error: file {path_} does not exist")
592
+ if not IS_IN_DOCKER:
593
+ if not os.path.exists(path_):
594
+ raise Exception(f"Error: file {path_} does not exist")
550
595
 
551
- with open(path_) as f:
552
- apply_diff_to = f.read()
596
+ with open(path_) as f:
597
+ apply_diff_to = f.read()
598
+ else:
599
+ # Copy from docker
600
+ with TemporaryDirectory() as tmpdir:
601
+ rcode = os.system(f"docker cp {IS_IN_DOCKER}:{path_} {tmpdir}")
602
+ if rcode != 0:
603
+ raise Exception(f"Error: Read failed with code {rcode}")
604
+ path_tmp = os.path.join(tmpdir, os.path.basename(path_))
605
+ with open(path_tmp, "r") as f:
606
+ apply_diff_to = f.read()
553
607
 
554
608
  fedit.file_edit_using_search_replace_blocks = (
555
609
  fedit.file_edit_using_search_replace_blocks.strip()
@@ -597,40 +651,20 @@ def do_diff_edit(fedit: FileEdit) -> str:
597
651
  "Error: no valid search-replace blocks found, please check your syntax for FileEdit"
598
652
  )
599
653
 
600
- with open(path_, "w") as f:
601
- f.write(apply_diff_to)
602
-
603
- return "Success"
604
-
605
-
606
- def file_edit(fedit: FileEditFindReplace) -> str:
607
- if not os.path.isabs(fedit.file_path):
608
- raise Exception(
609
- f"Failure: file_path should be absolute path, current working directory is {CWD}"
610
- )
654
+ if not IS_IN_DOCKER:
655
+ with open(path_, "w") as f:
656
+ f.write(apply_diff_to)
611
657
  else:
612
- path_ = fedit.file_path
658
+ with TemporaryDirectory() as tmpdir:
659
+ path_tmp = os.path.join(tmpdir, os.path.basename(path_))
660
+ with open(path_tmp, "w") as f:
661
+ f.write(apply_diff_to)
662
+ os.chmod(path_tmp, 0o777)
663
+ # Copy to docker using docker cp
664
+ rcode = os.system(f"docker cp {path_tmp} {IS_IN_DOCKER}:{path_}")
665
+ if rcode != 0:
666
+ raise Exception(f"Error: Write failed with code {rcode}")
613
667
 
614
- if not os.path.exists(path_):
615
- raise Exception(f"Error: file {path_} does not exist")
616
-
617
- if not fedit.find_lines:
618
- raise Exception("Error: `find_lines` cannot be empty")
619
-
620
- out_string = "\n".join("> " + line for line in fedit.find_lines.split("\n"))
621
- in_string = "\n".join("< " + line for line in fedit.replace_with_lines.split("\n"))
622
- console.log(f"Editing file: {path_}\n---\n{out_string}\n---\n{in_string}\n---")
623
- try:
624
- with open(path_) as f:
625
- content = f.read()
626
-
627
- content = edit_content(content, fedit.find_lines, fedit.replace_with_lines)
628
-
629
- with open(path_, "w") as f:
630
- f.write(content)
631
- except OSError as e:
632
- raise Exception(f"Error: {e}")
633
- console.print(f"File written to {path_}")
634
668
  return "Success"
635
669
 
636
670
 
@@ -661,7 +695,6 @@ TOOLS = (
661
695
  | BashCommand
662
696
  | BashInteraction
663
697
  | ResetShell
664
- | Writefile
665
698
  | CreateFileNew
666
699
  | FileEditFindReplace
667
700
  | FileEdit
@@ -670,6 +703,10 @@ TOOLS = (
670
703
  | ReadImage
671
704
  | ReadFile
672
705
  | Initialize
706
+ | Mouse
707
+ | Keyboard
708
+ | ScreenShot
709
+ | GetScreenInfo
673
710
  )
674
711
 
675
712
 
@@ -687,8 +724,6 @@ def which_tool_name(name: str) -> Type[TOOLS]:
687
724
  return BashInteraction
688
725
  elif name == "ResetShell":
689
726
  return ResetShell
690
- elif name == "Writefile":
691
- return Writefile
692
727
  elif name == "CreateFileNew":
693
728
  return CreateFileNew
694
729
  elif name == "FileEditFindReplace":
@@ -705,6 +740,14 @@ def which_tool_name(name: str) -> Type[TOOLS]:
705
740
  return ReadFile
706
741
  elif name == "Initialize":
707
742
  return Initialize
743
+ elif name == "Mouse":
744
+ return Mouse
745
+ elif name == "Keyboard":
746
+ return Keyboard
747
+ elif name == "ScreenShot":
748
+ return ScreenShot
749
+ elif name == "GetScreenInfo":
750
+ return GetScreenInfo
708
751
  else:
709
752
  raise ValueError(f"Unknown tool name: {name}")
710
753
 
@@ -715,7 +758,6 @@ def get_tool_output(
715
758
  | BashCommand
716
759
  | BashInteraction
717
760
  | ResetShell
718
- | Writefile
719
761
  | CreateFileNew
720
762
  | FileEditFindReplace
721
763
  | FileEdit
@@ -723,19 +765,23 @@ def get_tool_output(
723
765
  | DoneFlag
724
766
  | ReadImage
725
767
  | Initialize
726
- | ReadFile,
768
+ | ReadFile
769
+ | Mouse
770
+ | Keyboard
771
+ | ScreenShot
772
+ | GetScreenInfo,
727
773
  enc: tiktoken.Encoding,
728
774
  limit: float,
729
775
  loop_call: Callable[[str, float], tuple[str, float]],
730
776
  max_tokens: Optional[int],
731
- ) -> tuple[str | ImageData | DoneFlag, float]:
777
+ ) -> tuple[list[str | ImageData | DoneFlag], float]:
778
+ global IS_IN_DOCKER
732
779
  if isinstance(args, dict):
733
780
  adapter = TypeAdapter[
734
781
  Confirmation
735
782
  | BashCommand
736
783
  | BashInteraction
737
784
  | ResetShell
738
- | Writefile
739
785
  | CreateFileNew
740
786
  | FileEditFindReplace
741
787
  | FileEdit
@@ -744,12 +790,15 @@ def get_tool_output(
744
790
  | ReadImage
745
791
  | ReadFile
746
792
  | Initialize
793
+ | Mouse
794
+ | Keyboard
795
+ | ScreenShot
796
+ | GetScreenInfo,
747
797
  ](
748
798
  Confirmation
749
799
  | BashCommand
750
800
  | BashInteraction
751
801
  | ResetShell
752
- | Writefile
753
802
  | CreateFileNew
754
803
  | FileEditFindReplace
755
804
  | FileEdit
@@ -758,6 +807,10 @@ def get_tool_output(
758
807
  | ReadImage
759
808
  | ReadFile
760
809
  | Initialize
810
+ | Mouse
811
+ | Keyboard
812
+ | ScreenShot
813
+ | GetScreenInfo
761
814
  )
762
815
  arg = adapter.validate_python(args)
763
816
  else:
@@ -768,16 +821,10 @@ def get_tool_output(
768
821
  output = ask_confirmation(arg), 0.0
769
822
  elif isinstance(arg, (BashCommand | BashInteraction)):
770
823
  console.print("Calling execute bash tool")
771
- output = execute_bash(enc, arg, max_tokens)
772
- elif isinstance(arg, Writefile):
773
- console.print("Calling write file tool")
774
- output = write_file(arg, False), 0
824
+ output = execute_bash(enc, arg, max_tokens, None)
775
825
  elif isinstance(arg, CreateFileNew):
776
826
  console.print("Calling write file tool")
777
827
  output = write_file(arg, True), 0
778
- elif isinstance(arg, FileEditFindReplace):
779
- console.print("Calling file edit tool")
780
- output = file_edit(arg), 0.0
781
828
  elif isinstance(arg, FileEdit):
782
829
  console.print("Calling full file edit tool")
783
830
  output = do_diff_edit(arg), 0.0
@@ -799,11 +846,50 @@ def get_tool_output(
799
846
  elif isinstance(arg, Initialize):
800
847
  console.print("Calling initial info tool")
801
848
  output = initial_info(), 0.0
849
+ elif isinstance(arg, (Mouse, Keyboard, ScreenShot, GetScreenInfo)):
850
+ console.print(f"Calling {type(arg).__name__} tool")
851
+ outputs_cost = run_computer_tool(arg), 0.0
852
+ console.print(outputs_cost[0][0])
853
+ outputs: list[ImageData | str | DoneFlag] = [outputs_cost[0][0]]
854
+ imgBs64 = outputs_cost[0][1]
855
+ if imgBs64:
856
+ console.print("Captured screenshot")
857
+ outputs.append(ImageData(media_type="image/png", data=imgBs64))
858
+ if not IS_IN_DOCKER:
859
+ try:
860
+ # At this point we should go into the docker env
861
+ res, _ = execute_bash(
862
+ enc,
863
+ BashCommand(
864
+ command=f"docker exec -it {arg.docker_image_id} sh"
865
+ ),
866
+ None,
867
+ 0.2,
868
+ )
869
+ # At this point we should go into the docker env
870
+ res, _ = execute_bash(
871
+ enc,
872
+ BashInteraction(
873
+ send_text=f"export PS1={PROMPT}", type="BashInteraction"
874
+ ),
875
+ None,
876
+ 0.2,
877
+ )
878
+ # Do chown of home dir
879
+ except Exception as e:
880
+ reset_shell()
881
+ raise Exception(
882
+ f"Some error happened while going inside docker. I've reset the shell. Please start again. Error {e}"
883
+ )
884
+ IS_IN_DOCKER = arg.docker_image_id
885
+ return outputs, outputs_cost[1]
802
886
  else:
803
887
  raise ValueError(f"Unknown tool: {arg}")
804
-
805
- console.print(str(output[0]))
806
- return output
888
+ if isinstance(output[0], str):
889
+ console.print(str(output[0]))
890
+ else:
891
+ console.print(f"Received {type(output[0])} from tool")
892
+ return [output[0]], output[1]
807
893
 
808
894
 
809
895
  History = list[ChatCompletionMessageParam]
@@ -818,7 +904,6 @@ class Mdata(BaseModel):
818
904
  data: (
819
905
  BashCommand
820
906
  | BashInteraction
821
- | Writefile
822
907
  | CreateFileNew
823
908
  | ResetShell
824
909
  | FileEditFindReplace
@@ -853,9 +938,10 @@ def register_client(server_url: str, client_uuid: str = "") -> None:
853
938
  if isinstance(mdata.data, str):
854
939
  raise Exception(mdata)
855
940
  try:
856
- output, cost = get_tool_output(
941
+ outputs, cost = get_tool_output(
857
942
  mdata.data, default_enc, 0.0, lambda x, y: ("", 0), 8000
858
943
  )
944
+ output = outputs[0]
859
945
  curr_cost += cost
860
946
  print(f"{curr_cost=}")
861
947
  except Exception as e:
@@ -893,12 +979,22 @@ def read_file(readfile: ReadFile, max_tokens: Optional[int]) -> str:
893
979
  if not os.path.isabs(readfile.file_path):
894
980
  return f"Failure: file_path should be absolute path, current working directory is {CWD}"
895
981
 
896
- path = Path(readfile.file_path)
897
- if not path.exists():
898
- return f"Error: file {readfile.file_path} does not exist"
982
+ if not IS_IN_DOCKER:
983
+ path = Path(readfile.file_path)
984
+ if not path.exists():
985
+ return f"Error: file {readfile.file_path} does not exist"
899
986
 
900
- with path.open("r") as f:
901
- content = f.read()
987
+ with path.open("r") as f:
988
+ content = f.read()
989
+
990
+ else:
991
+ return_code, content, stderr = command_run(
992
+ f"cat {readfile.file_path}",
993
+ )
994
+ if return_code != 0:
995
+ raise Exception(
996
+ f"Error: cat {readfile.file_path} failed with code {return_code}\nstdout: {content}\nstderr: {stderr}"
997
+ )
902
998
 
903
999
  if max_tokens is not None:
904
1000
  tokens = default_enc.encode(content)
wcgw/relay/serve.py CHANGED
@@ -23,7 +23,6 @@ from ..types_ import (
23
23
  Initialize,
24
24
  ReadFile,
25
25
  ResetShell,
26
- Writefile,
27
26
  Specials,
28
27
  )
29
28
 
@@ -32,7 +31,6 @@ class Mdata(BaseModel):
32
31
  data: (
33
32
  BashCommand
34
33
  | BashInteraction
35
- | Writefile
36
34
  | CreateFileNew
37
35
  | ResetShell
38
36
  | FileEditFindReplace
@@ -67,8 +65,7 @@ async def register_websocket(websocket: WebSocket, uuid: UUID) -> None:
67
65
  # receive client version
68
66
  client_version = await websocket.receive_text()
69
67
  sem_version_client = semantic_version.Version.coerce(client_version)
70
- sem_version_server = semantic_version.Version.coerce(
71
- CLIENT_VERSION_MINIMUM)
68
+ sem_version_server = semantic_version.Version.coerce(CLIENT_VERSION_MINIMUM)
72
69
  if sem_version_client < sem_version_server:
73
70
  await websocket.send_text(
74
71
  Mdata(
@@ -93,8 +90,7 @@ async def register_websocket(websocket: WebSocket, uuid: UUID) -> None:
93
90
  while True:
94
91
  received_data = await websocket.receive_text()
95
92
  if uuid not in gpts:
96
- raise fastapi.HTTPException(
97
- status_code=400, detail="No call made")
93
+ raise fastapi.HTTPException(status_code=400, detail="No call made")
98
94
  gpts[uuid](received_data)
99
95
  except WebSocketDisconnect:
100
96
  # Remove the client if the WebSocket is disconnected
@@ -281,9 +277,7 @@ async def read_file_endpoint(read_file_data: ReadFileWithUUID) -> str:
281
277
 
282
278
  gpts[user_id] = put_results
283
279
 
284
- await clients[user_id](
285
- Mdata(data=read_file_data, user_id=user_id)
286
- )
280
+ await clients[user_id](Mdata(data=read_file_data, user_id=user_id))
287
281
 
288
282
  start_time = time.time()
289
283
  while time.time() - start_time < 30:
@@ -293,6 +287,7 @@ async def read_file_endpoint(read_file_data: ReadFileWithUUID) -> str:
293
287
 
294
288
  raise fastapi.HTTPException(status_code=500, detail="Timeout error")
295
289
 
290
+
296
291
  class InitializeWithUUID(Initialize):
297
292
  user_id: UUID
298
293
 
@@ -311,9 +306,7 @@ async def initialize(initialize_data: InitializeWithUUID) -> str:
311
306
 
312
307
  gpts[user_id] = put_results
313
308
 
314
- await clients[user_id](
315
- Mdata(data=initialize_data, user_id=user_id)
316
- )
309
+ await clients[user_id](Mdata(data=initialize_data, user_id=user_id))
317
310
 
318
311
  start_time = time.time()
319
312
  while time.time() - start_time < 30:
wcgw/types_.py CHANGED
@@ -24,11 +24,6 @@ class ReadImage(BaseModel):
24
24
  type: Literal["ReadImage"]
25
25
 
26
26
 
27
- class Writefile(BaseModel):
28
- file_path: str
29
- file_content: str
30
-
31
-
32
27
  class CreateFileNew(BaseModel):
33
28
  file_path: str
34
29
  file_content: str
@@ -55,4 +50,45 @@ class FileEdit(BaseModel):
55
50
 
56
51
 
57
52
  class Initialize(BaseModel):
58
- type: Literal["Initialize"]
53
+ type: Literal["Initialize"]
54
+
55
+
56
+ class GetScreenInfo(BaseModel):
57
+ type: Literal["GetScreenInfo"]
58
+ docker_image_id: str
59
+
60
+
61
+ class ScreenShot(BaseModel):
62
+ type: Literal["ScreenShot"]
63
+ docker_image_id: str
64
+
65
+
66
+ class MouseMove(BaseModel):
67
+ x: int
68
+ y: int
69
+ type: Literal["MouseMove"]
70
+
71
+
72
+ class LeftClickDrag(BaseModel):
73
+ x: int
74
+ y: int
75
+
76
+
77
+ class MouseButton(BaseModel):
78
+ button_type: Literal[
79
+ "left_click",
80
+ "right_click",
81
+ "middle_click",
82
+ "double_click",
83
+ "scroll_up",
84
+ "scroll_down",
85
+ ]
86
+
87
+
88
+ class Mouse(BaseModel):
89
+ action: MouseButton | LeftClickDrag | MouseMove
90
+
91
+
92
+ class Keyboard(BaseModel):
93
+ action: Literal["key", "type"]
94
+ text: str