jarvis-ai-assistant 0.3.34__py3-none-any.whl → 0.4.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.
jarvis/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """Jarvis AI Assistant"""
3
3
 
4
- __version__ = "0.3.34"
4
+ __version__ = "0.4.0"
@@ -54,6 +54,9 @@ from jarvis.jarvis_agent.events import (
54
54
  from jarvis.jarvis_agent.user_interaction import UserInteractionHandler
55
55
  from jarvis.jarvis_agent.utils import join_prompts
56
56
  from jarvis.jarvis_utils.methodology import _load_all_methodologies
57
+ from jarvis.jarvis_agent.shell_input_handler import shell_input_handler
58
+ from jarvis.jarvis_agent.file_context_handler import file_context_handler
59
+ from jarvis.jarvis_agent.builtin_input_handler import builtin_input_handler
57
60
 
58
61
  # jarvis_platform 相关
59
62
  from jarvis.jarvis_platform.base import BasePlatform
@@ -274,7 +277,6 @@ class Agent:
274
277
  auto_complete: bool = False,
275
278
  output_handler: Optional[List[OutputHandlerProtocol]] = None,
276
279
  use_tools: Optional[List[str]] = None,
277
- input_handler: Optional[List[Callable[[str, Any], Tuple[str, bool]]]] = None,
278
280
  execute_tool_confirm: Optional[bool] = None,
279
281
  need_summary: bool = True,
280
282
  multiline_inputer: Optional[Callable[[str], str]] = None,
@@ -294,7 +296,6 @@ class Agent:
294
296
  summary_prompt: 任务总结提示模板
295
297
  auto_complete: 是否自动完成任务
296
298
  output_handler: 输出处理器列表
297
- input_handler: 输入处理器列表
298
299
  execute_tool_confirm: 执行工具前是否需要确认
299
300
  need_summary: 是否需要生成总结
300
301
  multiline_inputer: 多行输入处理器
@@ -327,7 +328,6 @@ class Agent:
327
328
  # 初始化处理器
328
329
  self._init_handlers(
329
330
  output_handler or [],
330
- input_handler,
331
331
  multiline_inputer,
332
332
  use_tools or [],
333
333
  )
@@ -395,14 +395,17 @@ class Agent:
395
395
  def _init_handlers(
396
396
  self,
397
397
  output_handler: List[OutputHandlerProtocol],
398
- input_handler: Optional[List[Callable[[str, Any], Tuple[str, bool]]]],
399
398
  multiline_inputer: Optional[Callable[[str], str]],
400
399
  use_tools: List[str],
401
400
  ):
402
401
  """初始化各种处理器"""
403
402
  self.output_handler = output_handler or [ToolRegistry()]
404
403
  self.set_use_tools(use_tools)
405
- self.input_handler = input_handler or []
404
+ self.input_handler = [
405
+ builtin_input_handler,
406
+ shell_input_handler,
407
+ file_context_handler,
408
+ ]
406
409
  self.multiline_inputer = multiline_inputer or get_multiline_input
407
410
 
408
411
  def _init_config(
@@ -1,6 +1,6 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """Agent管理器模块,负责Agent的初始化和任务执行"""
3
- from typing import Optional
3
+ from typing import Optional, Callable
4
4
 
5
5
  import typer
6
6
 
@@ -29,6 +29,8 @@ class AgentManager:
29
29
  restore_session: bool = False,
30
30
  use_methodology: Optional[bool] = None,
31
31
  use_analysis: Optional[bool] = None,
32
+ multiline_inputer: Optional[Callable[[str], str]] = None,
33
+ confirm_callback: Optional[Callable[[str, bool], bool]] = None,
32
34
  ):
33
35
  self.model_group = model_group
34
36
  self.tool_group = tool_group
@@ -36,6 +38,9 @@ class AgentManager:
36
38
  self.use_methodology = use_methodology
37
39
  self.use_analysis = use_analysis
38
40
  self.agent: Optional[Agent] = None
41
+ # 可选:注入输入与确认回调,用于Web模式等前端替代交互
42
+ self.multiline_inputer = multiline_inputer
43
+ self.confirm_callback = confirm_callback
39
44
 
40
45
  def initialize(self) -> Agent:
41
46
  """初始化Agent"""
@@ -48,15 +53,12 @@ class AgentManager:
48
53
  self.agent = Agent(
49
54
  system_prompt=origin_agent_system_prompt,
50
55
  model_group=self.model_group,
51
- input_handler=[
52
- shell_input_handler,
53
- file_context_handler,
54
- builtin_input_handler,
55
- ],
56
56
  output_handler=[ToolRegistry()], # type: ignore
57
57
  need_summary=False,
58
58
  use_methodology=self.use_methodology,
59
59
  use_analysis=self.use_analysis,
60
+ multiline_inputer=self.multiline_inputer,
61
+ confirm_callback=self.confirm_callback,
60
62
  )
61
63
 
62
64
  # 尝试恢复会话
@@ -28,6 +28,7 @@ from jarvis.jarvis_utils.fzf import fzf_select
28
28
  import os
29
29
  import subprocess
30
30
  from pathlib import Path
31
+ import signal
31
32
  import yaml # type: ignore
32
33
  from rich.table import Table
33
34
  from rich.console import Console
@@ -677,6 +678,10 @@ def run_cli(
677
678
  non_interactive: bool = typer.Option(
678
679
  False, "-n", "--non-interactive", help="启用非交互模式:用户无法与命令交互,脚本执行超时限制为5分钟"
679
680
  ),
681
+ web: bool = typer.Option(False, "--web", help="以 Web 模式启动,通过浏览器 WebSocket 交互"),
682
+ web_host: str = typer.Option("127.0.0.1", "--web-host", help="Web 服务主机"),
683
+ web_port: int = typer.Option(8765, "--web-port", help="Web 服务端口"),
684
+ stop: bool = typer.Option(False, "--stop", help="停止后台 Web 服务(需与 --web 一起使用)"),
680
685
  ) -> None:
681
686
  """Jarvis AI assistant command-line interface."""
682
687
  if ctx.invoked_subcommand is not None:
@@ -742,16 +747,234 @@ def run_cli(
742
747
 
743
748
  # 预加载配置(仅用于读取功能开关),不会显示欢迎信息或影响后续 init_env
744
749
  preload_config_for_flags(config_file)
750
+ # Web 模式后台管理:支持 --web 后台启动与 --web --stop 停止
751
+ if web:
752
+ # PID 文件路径(按端口区分,便于多实例)
753
+ pidfile = Path(os.path.expanduser("~/.jarvis")) / f"jarvis_web_{web_port}.pid"
754
+ # 停止后台服务
755
+ if stop:
756
+ try:
757
+ pf = pidfile
758
+ if not pf.exists():
759
+ # 兼容旧版本:回退检查数据目录中的旧 PID 文件位置
760
+ try:
761
+ pf_alt = Path(os.path.expanduser(os.path.expandvars(get_data_dir()))) / f"jarvis_web_{web_port}.pid"
762
+ except Exception:
763
+ pf_alt = None # type: ignore[assignment]
764
+ if pf_alt and pf_alt.exists(): # type: ignore[truthy-bool]
765
+ pf = pf_alt
766
+ if not pf.exists():
767
+ # 进一步回退:尝试按端口查找并停止(无 PID 文件)
768
+ killed_any = False
769
+ try:
770
+ res = subprocess.run(
771
+ ["lsof", "-iTCP:%d" % web_port, "-sTCP:LISTEN", "-t"],
772
+ capture_output=True,
773
+ text=True,
774
+ )
775
+ if res.returncode == 0 and res.stdout.strip():
776
+ for ln in res.stdout.strip().splitlines():
777
+ try:
778
+ candidate_pid = int(ln.strip())
779
+ try:
780
+ os.kill(candidate_pid, signal.SIGTERM)
781
+ PrettyOutput.print(f"已按端口停止后台 Web 服务 (PID {candidate_pid})。", OutputType.SUCCESS)
782
+ killed_any = True
783
+ except Exception as e:
784
+ PrettyOutput.print(f"按端口停止失败: {e}", OutputType.WARNING)
785
+ except Exception:
786
+ continue
787
+ except Exception:
788
+ pass
789
+ if not killed_any:
790
+ try:
791
+ res2 = subprocess.run(["ss", "-ltpn"], capture_output=True, text=True)
792
+ if res2.returncode == 0 and res2.stdout:
793
+ for ln in res2.stdout.splitlines():
794
+ if f":{web_port} " in ln or f":{web_port}\n" in ln:
795
+ try:
796
+ idx = ln.find("pid=")
797
+ if idx != -1:
798
+ end = ln.find(",", idx)
799
+ pid_str2 = ln[idx+4:end if end != -1 else None]
800
+ candidate_pid = int(pid_str2)
801
+ try:
802
+ os.kill(candidate_pid, signal.SIGTERM)
803
+ PrettyOutput.print(f"已按端口停止后台 Web 服务 (PID {candidate_pid})。", OutputType.SUCCESS)
804
+ killed_any = True
805
+ except Exception as e:
806
+ PrettyOutput.print(f"按端口停止失败: {e}", OutputType.WARNING)
807
+ break
808
+ except Exception:
809
+ continue
810
+ except Exception:
811
+ pass
812
+ # 若仍未找到,扫描家目录下所有 Web PID 文件,尽力停止所有实例
813
+ if not killed_any:
814
+ try:
815
+ pid_dir = Path(os.path.expanduser("~/.jarvis"))
816
+ if pid_dir.is_dir():
817
+ for f in pid_dir.glob("jarvis_web_*.pid"):
818
+ try:
819
+ ptxt = f.read_text(encoding="utf-8").strip()
820
+ p = int(ptxt)
821
+ try:
822
+ os.kill(p, signal.SIGTERM)
823
+ PrettyOutput.print(f"已停止后台 Web 服务 (PID {p})。", OutputType.SUCCESS)
824
+ killed_any = True
825
+ except Exception as e:
826
+ PrettyOutput.print(f"停止 PID {p} 失败: {e}", OutputType.WARNING)
827
+ except Exception:
828
+ pass
829
+ try:
830
+ f.unlink(missing_ok=True)
831
+ except Exception:
832
+ pass
833
+ except Exception:
834
+ pass
835
+ if not killed_any:
836
+ PrettyOutput.print("未找到后台 Web 服务的 PID 文件,可能未启动或已停止。", OutputType.WARNING)
837
+ return
838
+ # 优先使用 PID 文件中的 PID
839
+ try:
840
+ pid_str = pf.read_text(encoding="utf-8").strip()
841
+ pid = int(pid_str)
842
+ except Exception:
843
+ pid = 0
844
+ killed = False
845
+ if pid > 0:
846
+ try:
847
+ os.kill(pid, signal.SIGTERM)
848
+ PrettyOutput.print(f"已向后台 Web 服务发送停止信号 (PID {pid})。", OutputType.SUCCESS)
849
+ killed = True
850
+ except Exception as e:
851
+ PrettyOutput.print(f"发送停止信号失败或进程不存在: {e}", OutputType.WARNING)
852
+ if not killed:
853
+ # 无 PID 文件或停止失败时,尝试按端口查找进程
854
+ candidate_pid = 0
855
+ try:
856
+ res = subprocess.run(
857
+ ["lsof", "-iTCP:%d" % web_port, "-sTCP:LISTEN", "-t"],
858
+ capture_output=True,
859
+ text=True,
860
+ )
861
+ if res.returncode == 0 and res.stdout.strip():
862
+ for ln in res.stdout.strip().splitlines():
863
+ try:
864
+ candidate_pid = int(ln.strip())
865
+ break
866
+ except Exception:
867
+ continue
868
+ except Exception:
869
+ pass
870
+ if not candidate_pid:
871
+ try:
872
+ res2 = subprocess.run(["ss", "-ltpn"], capture_output=True, text=True)
873
+ if res2.returncode == 0 and res2.stdout:
874
+ for ln in res2.stdout.splitlines():
875
+ if f":{web_port} " in ln or f":{web_port}\n" in ln:
876
+ # 格式示例: LISTEN ... users:(("uvicorn",pid=12345,fd=7))
877
+ try:
878
+ idx = ln.find("pid=")
879
+ if idx != -1:
880
+ end = ln.find(",", idx)
881
+ pid_str2 = ln[idx+4:end if end != -1 else None]
882
+ candidate_pid = int(pid_str2)
883
+ break
884
+ except Exception:
885
+ continue
886
+ except Exception:
887
+ pass
888
+ if candidate_pid:
889
+ try:
890
+ os.kill(candidate_pid, signal.SIGTERM)
891
+ PrettyOutput.print(f"已按端口停止后台 Web 服务 (PID {candidate_pid})。", OutputType.SUCCESS)
892
+ killed = True
893
+ except Exception as e:
894
+ PrettyOutput.print(f"按端口停止失败: {e}", OutputType.WARNING)
895
+ # 清理可能存在的 PID 文件(两个位置)
896
+ try:
897
+ pidfile.unlink(missing_ok=True) # 家目录位置
898
+ except Exception:
899
+ pass
900
+ try:
901
+ alt_pf = Path(os.path.expanduser(os.path.expandvars(get_data_dir()))) / f"jarvis_web_{web_port}.pid"
902
+ alt_pf.unlink(missing_ok=True)
903
+ except Exception:
904
+ pass
905
+ except Exception as e:
906
+ PrettyOutput.print(f"停止后台 Web 服务失败: {e}", OutputType.ERROR)
907
+ finally:
908
+ return
909
+ # 后台启动:父进程拉起子进程并记录 PID
910
+ is_daemon = False
911
+ try:
912
+ is_daemon = os.environ.get("JARVIS_WEB_DAEMON") == "1"
913
+ except Exception:
914
+ is_daemon = False
915
+ if not is_daemon:
916
+ try:
917
+ # 构建子进程参数,传递关键配置
918
+ args = [
919
+ sys.executable,
920
+ "-m",
921
+ "jarvis.jarvis_agent.jarvis",
922
+ "--web",
923
+ "--web-host",
924
+ str(web_host),
925
+ "--web-port",
926
+ str(web_port),
927
+ ]
928
+ if model_group:
929
+ args += ["-g", str(model_group)]
930
+ if tool_group:
931
+ args += ["-G", str(tool_group)]
932
+ if config_file:
933
+ args += ["-f", str(config_file)]
934
+ if restore_session:
935
+ args += ["--restore-session"]
936
+ if disable_methodology_analysis:
937
+ args += ["-D"]
938
+ if non_interactive:
939
+ args += ["-n"]
940
+ env = os.environ.copy()
941
+ env["JARVIS_WEB_DAEMON"] = "1"
942
+ # 启动子进程(后台运行)
943
+ proc = subprocess.Popen(
944
+ args,
945
+ env=env,
946
+ stdout=subprocess.DEVNULL,
947
+ stderr=subprocess.DEVNULL,
948
+ stdin=subprocess.DEVNULL,
949
+ close_fds=True,
950
+ )
951
+ # 记录 PID 到文件
952
+ try:
953
+ pidfile.parent.mkdir(parents=True, exist_ok=True)
954
+ except Exception:
955
+ pass
956
+ try:
957
+ pidfile.write_text(str(proc.pid), encoding="utf-8")
958
+ except Exception:
959
+ pass
960
+ PrettyOutput.print(
961
+ f"Web 服务已在后台启动 (PID {proc.pid}),地址: http://{web_host}:{web_port}",
962
+ OutputType.SUCCESS,
963
+ )
964
+ except Exception as e:
965
+ PrettyOutput.print(f"后台启动 Web 服务失败: {e}", OutputType.ERROR)
966
+ raise typer.Exit(code=1)
967
+ return
745
968
 
746
969
  # 在初始化环境前检测Git仓库,并可选择自动切换到代码开发模式(jca)
747
- if not non_interactive:
970
+ if not non_interactive and not web:
748
971
  try_switch_to_jca_if_git_repo(
749
972
  model_group, tool_group, config_file, restore_session, task
750
973
  )
751
974
 
752
975
  # 在进入默认通用代理前,列出内置配置供选择(agent/multi_agent/roles)
753
976
  # 非交互模式下跳过内置角色/配置选择
754
- if not non_interactive:
977
+ if not non_interactive and not web:
755
978
  handle_builtin_config_selector(model_group, tool_group, config_file, task)
756
979
 
757
980
  # 初始化环境
@@ -779,14 +1002,64 @@ def run_cli(
779
1002
 
780
1003
  # 运行主流程
781
1004
  try:
1005
+ # 在 Web 模式下注入基于 WebSocket 的输入/确认回调
1006
+ extra_kwargs = {}
1007
+ if web:
1008
+ try:
1009
+ from jarvis.jarvis_agent.web_bridge import web_multiline_input, web_user_confirm
1010
+ extra_kwargs["multiline_inputer"] = web_multiline_input
1011
+ extra_kwargs["confirm_callback"] = web_user_confirm
1012
+ except Exception as e:
1013
+ PrettyOutput.print(f"Web 模式初始化失败(加载 Web 桥接模块): {e}", OutputType.ERROR)
1014
+ raise typer.Exit(code=1)
1015
+
782
1016
  agent_manager = AgentManager(
783
1017
  model_group=model_group,
784
1018
  tool_group=tool_group,
785
1019
  restore_session=restore_session,
786
1020
  use_methodology=False if disable_methodology_analysis else None,
787
1021
  use_analysis=False if disable_methodology_analysis else None,
1022
+ **extra_kwargs,
788
1023
  )
789
- agent_manager.initialize()
1024
+ agent = agent_manager.initialize()
1025
+
1026
+ if web:
1027
+ try:
1028
+
1029
+ from jarvis.jarvis_agent.web_server import start_web_server
1030
+ from jarvis.jarvis_agent.stdio_redirect import enable_web_stdio_redirect, enable_web_stdin_redirect
1031
+ # 在 Web 模式下固定TTY宽度为200,改善前端显示效果
1032
+ try:
1033
+ import os as _os
1034
+ _os.environ["COLUMNS"] = "200"
1035
+ # 尝试固定全局 Console 的宽度(PrettyOutput 使用该 Console 实例)
1036
+ try:
1037
+ from jarvis.jarvis_utils.globals import console as _console
1038
+ try:
1039
+ _console._width = 200 # rich Console的固定宽度参数
1040
+ except Exception:
1041
+ pass
1042
+ except Exception:
1043
+ pass
1044
+ except Exception:
1045
+ pass
1046
+ # 使用 STDIO 重定向,取消 Sink 广播以避免重复输出
1047
+ # 启用标准输出/错误的WebSocket重定向(捕获工具直接打印的输出)
1048
+ enable_web_stdio_redirect()
1049
+ # 启用来自前端 xterm 的 STDIN 重定向,使交互式命令可从浏览器获取输入
1050
+ try:
1051
+ enable_web_stdin_redirect()
1052
+ except Exception:
1053
+ pass
1054
+ PrettyOutput.print("以 Web 模式启动,请在浏览器中打开提供的地址进行交互。", OutputType.INFO)
1055
+ # 启动 Web 服务(阻塞调用)
1056
+ start_web_server(agent_manager, host=web_host, port=web_port)
1057
+ return
1058
+ except Exception as e:
1059
+ PrettyOutput.print(f"Web 模式启动失败: {e}", OutputType.ERROR)
1060
+ raise typer.Exit(code=1)
1061
+
1062
+ # 默认 CLI 模式:运行任务(可能来自 --task 或交互输入)
790
1063
  agent_manager.run_task(task)
791
1064
  except typer.Exit:
792
1065
  raise