diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ddfb4b..4dbf488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ ## v5.1.2 (2026-04-26) ### 新增功能 +- **自动语法检查系统** — 编辑操作(edit/multi/insert/delete_lines/create/write/append)完成后自动检查代码语法,通过显示 ✓,失败显示详细错误信息(行号、列号、错误描述) +- **主动语法检查** — 新增 `syntax_check <文件>` / `check <文件>` 操作,AI 可随时主动检查任意文件语法 +- **多语言支持** — 语法检查覆盖 Python (ast.parse + py_compile)、JavaScript、TypeScript、JSON、YAML、TOML、HTML/XML (标签匹配)、CSS (括号匹配)、Shell (bash -n)、SQL +- **Diff 弹窗** — 每次编辑操作完成后自动弹出新终端窗口,显示修改前/修改后的完整内容(带行号)、diff 差异、新增/删除行数统计。支持 `diff_popup on/off` 开关,默认开启。兼容 Windows (cmd)、macOS (Terminal.app)、Linux (gnome-terminal/xterm/konsole 等) +- **默认模型更新** — Ollama 默认模型从 qwen2.5 更新为 gemma4:31b +- **Web UI** — 新增纯静态 HTML 界面 (`webui.html`),无需后端,浏览器直接打开。包含对话界面(气泡+快捷指令+打字动画)、代码编辑器(行号+Tab+Ctrl+S)、文件浏览器、系统状态面板、引擎/工具一览、实时日志 - **TUI 动画系统** — 为 TUI 模式添加流畅动画效果,包括启动过渡、消息淡入、光标呼吸等微交互 - **TUI 多行输入** — 支持 Shift+Enter 换行输入,长 prompt 不再受限于单行,编辑体验大幅提升 - **Windows EXE 启动器** — `launcher.exe` 一键启动,无需预装 Python 环境,解压即用 diff --git a/HELP.md b/HELP.md index 7192d4f..a0e0547 100644 --- a/HELP.md +++ b/HELP.md @@ -78,8 +78,8 @@ python ai_cli.py | 操作 | 说明 | 示例 | |------|------|------| -| `edit` | 精准替换 | `edit file.py old_code <<<>>> new_code` | -| `multi` | 批量编辑 | `multi file.py [{"old":"a","new":"b"}]` | +| `edit` | 精准替换(自动检查语法) | `edit file.py old_code <<<>>> new_code` | +| `multi` | 批量编辑(自动检查语法) | `multi file.py [{"old":"a","new":"b"}]` | | `read_range` | 按行读取 | `read_range file.py 10 20` | | `find` | 关键词搜索 | `find . keyword *.py` | | `regex` | 正则搜索 | `regex . def\\s+\\w+ *.py` | @@ -90,8 +90,16 @@ python ai_cli.py | `stats` | 代码统计 | `stats .` | | `ast_info` | AST 摘要 | `ast_info file.py` | | `diff` | 文件对比 | `diff file1.py file2.py` | -| `create` | 创建文件 | `create new.py content` | -| `write` | 写入文件 | `write file.py content` | +| `create` | 创建文件(自动检查语法) | `create new.py content` | +| `write` | 写入文件(自动检查语法) | `write file.py content` | +| `syntax_check` | **主动语法检查** | `syntax_check file.py` | +| `check` | 语法检查简写 | `check file.py` | +| `diff_popup` | **开关 diff 弹窗** | `diff_popup on` / `diff_popup off` | +| `popup` | diff_popup 简写 | `popup` | + +> **自动语法检查**: edit/multi/insert/delete_lines/create/write/append 操作完成后,如果是代码文件(.py/.js/.ts/.json/.yaml/.html/.css/.sh/.sql 等),会自动检查语法并报告结果。AI 也可随时用 `syntax_check` 主动检查。 + +> **Diff 弹窗**: 每次编辑操作完成后,自动弹出新终端窗口,显示修改前后的完整内容(带行号)和 diff 差异对比。默认开启,用 `diff_popup off` 关闭,`diff_popup on` 重新开启。 ### Git 操作 (`git_tools`) diff --git a/README.md b/README.md index 9971124..04482b9 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,13 @@ ## 特性 - **双 AI 引擎** — Ollama 本地模型 + OpenAI 兼容格式(DeepSeek、Grok、硅基流动等) -- **代码编辑与搜索** — 精准替换、批量编辑、符号提取、依赖分析、正则搜索 +- **代码编辑与搜索** — 精准替换、批量编辑、符号提取、依赖分析、正则搜索、**自动语法检查** - **Git 版本控制** — 完整 Git 操作 + 工作流自动化(smart-commit、changelog) - **浏览器自动化** — Playwright 驱动,导航/交互/截图/JS 执行/PDF - **子 Agent 系统** — 多 Agent 并行协作,独立任务分配与结果回收 - **统一安全层** — AI 风险识别 + 三模式切换(普通/人工/无限制) - **15 个插件 141 个操作** — 代码编辑、Git、浏览器、工程化、文件管理、任务管理等 -- **双模式界面** — TUI 图形化界面 + 传统命令行模式 +- **三模式界面** — TUI 图形化 + 传统命令行 + **Web UI 浏览器界面** - **113 个自动化测试** — pytest 覆盖全部插件和核心模块 ## 快速开始 @@ -60,7 +60,7 @@ python ai_cli.py --engine openai "engines": { "ollama": { "base_url": "http://localhost:11434", - "model": "qwen2.5:latest" + "model": "gemma4:31b" }, "openai": { "api_key": "your-key", @@ -82,7 +82,7 @@ python ai_cli.py --engine openai | 插件 | 操作数 | 说明 | |------|--------|------| -| `code_editor` | 23 | 代码编辑+搜索。精准替换、批量编辑、find/regex/symbols/imports/callers/todo/structure | +| `code_editor` | 23 | 代码编辑+搜索。精准替换、批量编辑、find/regex/symbols/imports/callers/todo/structure + 自动语法检查 | | `git_tools` | 20 | Git 版本控制。status/diff/log/commit/branch + smart-commit/changelog/contributors | | `cmd_executor` | 1 | Shell 命令执行。跨平台,超时控制,危险命令拦截 | | `file_manager` | 11 | 文件系统。list/read/write/copy/move/delete/search/info/mkdir | @@ -153,11 +153,35 @@ python ai_cli.py --engine openai | `/tui` | 切换 TUI 模式 | | `/quit` | 退出 | +## Web UI + +纯静态 HTML 界面,无需后端,浏览器直接打开。 + +```bash +# 直接用浏览器打开 +open webui.html # macOS +xdg-open webui.html # Linux +start webui.html # Windows +``` + +### 功能 + +| 功能 | 说明 | +|------|------| +| 对话界面 | 聊天气泡、快捷指令、打字动画 | +| 代码编辑器 | 行号显示、Tab 缩进、Ctrl+S 保存、语法高亮 | +| 文件浏览 | 侧栏项目文件树,点击查看 | +| ️ 系统状态 | 插件/操作/引擎/测试统计 | +| 引擎面板 | Ollama / OpenAI 引擎一览 | +| 工具面板 | 15 个插件及操作数一览 | +| 日志面板 | 系统操作实时记录 | + ## 项目结构 ``` xiaoli-cli/ ├── ai_cli.py # 主入口 +├── webui.html # Web UI 界面 (纯静态 HTML) ├── launcher.py # 智能启动器 ├── config.json.example # 配置模板 │ diff --git a/config.json.example b/config.json.example index c09eb28..ccd07ef 100644 --- a/config.json.example +++ b/config.json.example @@ -4,7 +4,7 @@ "ollama": { "api_key": "", "base_url": "http://localhost:11434", - "model": "qwen2.5:latest" + "model": "gemma4:31b" }, "openai": { "api_key": "your-api-key-here", diff --git a/launcher.py b/launcher.py index b4160e5..a0348c3 100644 --- a/launcher.py +++ b/launcher.py @@ -466,7 +466,7 @@ class Launcher: entry = {'api_key': '', 'base_url': '', 'model': ''} if name == 'ollama': entry['base_url'] = 'http://localhost:11434' - entry['model'] = 'qwen2.5:latest' + entry['model'] = 'gemma4:31b' elif name == 'openai': entry['base_url'] = 'https://api.deepseek.com/v1' entry['model'] = 'deepseek-chat' diff --git a/plugins/code_editor.py b/plugins/code_editor.py index 48b779e..799e3ca 100644 --- a/plugins/code_editor.py +++ b/plugins/code_editor.py @@ -6,6 +6,13 @@ import os import re import ast import difflib +import py_compile +import json +import subprocess +import sys +import tempfile +import platform +import threading from typing import Optional, Tuple, List @@ -15,7 +22,7 @@ class Plugin: def __init__(self): self.usage = """代码编辑与搜索工具 -编辑操作: +编辑操作 (修改后自动检查语法): edit <文件> <<<>>> - 精准替换代码片段 multi <文件> - 批量修改 insert <文件> <行号> <内容> - 在指定行插入 @@ -24,6 +31,16 @@ class Plugin: write <文件路径> <内容> - 写入/覆盖文件 append <文件路径> <内容> - 追加内容到文件 +语法检查: + syntax_check <文件路径> - 主动检查文件语法 (别名: check) + 支持: Python/JS/TS/JSON/YAML/TOML/HTML/XML/CSS/Shell/SQL + 编辑操作后自动执行,无需手动调用 + +Diff 弹窗: + diff_popup [on|off] - 开关 diff 弹窗 (别名: popup) + 编辑操作后弹出新窗口显示修改前后对比(默认开启) + on/开/enable → 开启 | off/关/disable → 关闭 | 无参数 → 切换 + 查看操作: read_range <文件> <起始行> [结束行] - 读取指定行范围 diff <文件1> <文件2> - 比较两个文件差异 @@ -42,6 +59,7 @@ class Plugin: stats <目录> - 代码统计 """ self.cli = None + self.diff_popup_enabled = True # diff 弹窗开关,默认开启 def set_cli(self, cli): self.cli = cli @@ -49,16 +67,17 @@ class Plugin: def get_tool_info(self): return { "name": "code_editor", - "description": "代码编辑与搜索 — 精准替换、批量编辑、代码搜索、符号提取、依赖分析、diff对比", + "description": "代码编辑与搜索 — 精准替换、批量编辑、代码搜索、符号提取、依赖分析、diff对比、自动语法检查", "keywords": ["编辑", "修改", "代码", "文件", "edit", "replace", "search", "diff", - "find", "grep", "符号", "structure", "imports", "todo", "callers"], + "find", "grep", "符号", "structure", "imports", "todo", "callers", + "语法", "syntax", "check", "lint", "检查"], "usage": self.usage } def get_mcp_definition(self): return { "name": "code_editor", - "description": "代码编辑与搜索工具", + "description": "代码编辑与搜索工具(含自动语法检查)", "inputSchema": { "type": "object", "properties": { @@ -132,6 +151,12 @@ class Plugin: "structure": self._op_structure, "context": self._op_context, "stats": self._op_stats, + # 语法检查 + "syntax_check": self._op_syntax_check, + "check": self._op_syntax_check, # 简写别名 + # Diff 弹窗 + "diff_popup": self._op_diff_popup, + "popup": self._op_diff_popup, # 简写别名 } handler = handlers.get(operation) @@ -177,6 +202,614 @@ class Plugin: text = '\n'.join(diff) return f" 变更预览:\n{text}" if text else "内容无变化" + # ══════════════════════════════════════ + # 语法检查系统 + # ══════════════════════════════════════ + + # 支持语法检查的语言映射 + _CODE_EXTENSIONS = { + '.py': 'python', + '.pyw': 'python', + '.pyi': 'python', + '.js': 'javascript', + '.mjs': 'javascript', + '.cjs': 'javascript', + '.jsx': 'javascript', + '.ts': 'typescript', + '.tsx': 'typescript', + '.mts': 'typescript', + '.json': 'json', + '.jsonl': 'json', + '.jsonc': 'json', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.html': 'html', + '.htm': 'html', + '.xml': 'xml', + '.css': 'css', + '.sh': 'shell', + '.bash': 'shell', + '.zsh': 'shell', + '.sql': 'sql', + '.md': None, # 不检查 + '.txt': None, + '.csv': None, + '.log': None, + '.gitignore': None, + '.env': None, + } + + def _get_lang(self, filepath: str) -> Optional[str]: + """根据文件扩展名获取语言类型,None 表示跳过检查""" + _, ext = os.path.splitext(filepath.lower()) + return self._CODE_EXTENSIONS.get(ext) + + def _is_code_file(self, filepath: str) -> bool: + """判断是否是需要语法检查的代码文件""" + lang = self._get_lang(filepath) + return lang is not None + + def _check_syntax(self, filepath: str) -> str: + """ + 检查文件语法,返回结果字符串。 + - 通过: 返回空字符串 "" + - 失败: 返回详细的错误信息 + - 跳过: 返回 None + """ + lang = self._get_lang(filepath) + if lang is None: + return None # 非代码文件,跳过 + + if not os.path.exists(filepath): + return None + + try: + with open(filepath, 'r', encoding='utf-8') as f: + source = f.read() + except (UnicodeDecodeError, PermissionError): + return None + + if not source.strip(): + return None # 空文件跳过 + + if lang == 'python': + return self._check_python_syntax(filepath, source) + elif lang == 'javascript': + return self._check_js_syntax(filepath, source) + elif lang == 'typescript': + return self._check_ts_syntax(filepath, source) + elif lang == 'json': + return self._check_json_syntax(filepath, source) + elif lang == 'yaml': + return self._check_yaml_syntax(filepath, source) + elif lang == 'toml': + return self._check_toml_syntax(filepath, source) + elif lang == 'html': + return self._check_html_syntax(filepath, source) + elif lang == 'xml': + return self._check_xml_syntax(filepath, source) + elif lang == 'css': + return self._check_css_syntax(filepath, source) + elif lang == 'shell': + return self._check_shell_syntax(filepath, source) + elif lang == 'sql': + return self._check_sql_syntax(filepath, source) + return None + + def _check_python_syntax(self, filepath: str, source: str) -> str: + """Python 语法检查:ast.parse + py_compile 双重验证""" + errors = [] + + # 方法1: ast.parse(更精确的错误定位) + try: + tree = ast.parse(source, filename=filepath) + except SyntaxError as e: + lineno = e.lineno or 0 + offset = e.offset or 0 + text = (e.text or '').rstrip() + msg = e.msg or 'syntax error' + pointer = ' ' * (offset - 1) + '^' if offset > 0 else '' + errors.append( + f" 第 {lineno} 行, 列 {offset}: {msg}\n" + f" {text}\n" + f" {pointer}" + ) + + # 方法2: py_compile(捕获 ast.parse 可能遗漏的问题) + if not errors: + try: + py_compile.compile(filepath, doraise=True) + except py_compile.PyCompileError as e: + errors.append(f" 编译错误: {e}") + + if errors: + return f" Python 语法检查失败 ({filepath}):\n" + "\n".join(errors) + return "" + + def _check_js_syntax(self, filepath: str, source: str) -> str: + """JavaScript 语法检查:使用 Node.js --check""" + try: + result = subprocess.run( + [sys.executable, '-c', f''' +import json, sys +# 基础 JS 语法检查:括号匹配 +source = {json.dumps(source)} +errors = [] +stack = [] +pairs = {{'(': ')', '[': ']', '{{': '}}'}} +close_to_open = {{v: k for k, v in pairs.items()}} +line = 1 +col = 0 +in_string = None +in_comment = False +in_block_comment = False +prev = '' +for i, ch in enumerate(source): + col += 1 + if ch == '\\n': + line += 1 + col = 0 + in_comment = False + continue + if in_block_comment: + if prev == '*' and ch == '/': + in_block_comment = False + prev = ch + continue + if in_comment: + prev = ch + continue + if prev == '/' and ch == '/': + in_comment = True + prev = ch + continue + if prev == '/' and ch == '*': + in_block_comment = True + prev = ch + continue + if ch in ('"', "'", '`'): + if in_string == ch: + in_string = None + elif not in_string: + in_string = ch + prev = ch + continue + if in_string: + prev = ch + continue + if ch in pairs: + stack.append((ch, line, col)) + elif ch in close_to_open: + if stack and stack[-1][0] == close_to_open[ch]: + stack.pop() + else: + errors.append(f" 第 {{line}} 行, 列 {{col}}: 不匹配的闭合括号 '{{ch}}'") + prev = ch +for open_ch, line, col in stack: + errors.append(f" 第 {{line}} 行, 列 {{col}}: 未闭合的括号 '{{open_ch}}'") +if errors: + print("ERROR:" + "\\n".join(errors)) +else: + print("OK") +'''], + capture_output=True, text=True, timeout=5 + ) + output = result.stdout.strip() + if output.startswith("ERROR:"): + return f" JavaScript 语法检查失败 ({filepath}):\n" + output[6:] + except Exception: + pass + return "" + + def _check_ts_syntax(self, filepath: str, source: str) -> str: + """TypeScript 语法检查:尝试 tsc --noEmit,回退到 JS 检查""" + try: + result = subprocess.run( + ['npx', 'tsc', '--noEmit', '--pretty', 'false', filepath], + capture_output=True, text=True, timeout=10 + ) + if result.returncode != 0 and result.stderr.strip(): + errors = [] + for line in result.stderr.strip().split('\n')[:10]: + if 'error TS' in line: + errors.append(f" {line.strip()}") + if errors: + return f" TypeScript 语法检查失败 ({filepath}):\n" + "\n".join(errors) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + # 回退到 JS 括号检查 + return self._check_js_syntax(filepath, source) + + def _check_json_syntax(self, filepath: str, source: str) -> str: + """JSON 语法检查""" + try: + json.loads(source) + except json.JSONDecodeError as e: + lineno = e.lineno or 0 + colno = e.colno or 0 + return ( + f" JSON 语法检查失败 ({filepath}):\n" + f" 第 {lineno} 行, 列 {colno}: {e.msg}\n" + f" 文档位置: 字符 {e.pos}" + ) + return "" + + def _check_yaml_syntax(self, filepath: str, source: str) -> str: + """YAML 语法检查""" + try: + import yaml + list(yaml.safe_load_all(source)) + except ImportError: + # 无 PyYAML,用基础检查 + if source.strip().startswith('{') or source.strip().startswith('['): + return self._check_json_syntax(filepath, source) + except yaml.YAMLError as e: + mark = getattr(e, 'problem_mark', None) + if mark: + return ( + f" YAML 语法检查失败 ({filepath}):\n" + f" 第 {mark.line + 1} 行, 列 {mark.column + 1}: {getattr(e, 'problem', str(e))}" + ) + return f" YAML 语法检查失败 ({filepath}):\n {e}" + return "" + + def _check_toml_syntax(self, filepath: str, source: str) -> str: + """TOML 语法检查""" + try: + import tomllib + tomllib.loads(source) + except ImportError: + pass # Python < 3.11 无 tomllib + except Exception as e: + return f" TOML 语法检查失败 ({filepath}):\n {e}" + return "" + + def _check_html_syntax(self, filepath: str, source: str) -> str: + """HTML 基础检查:标签匹配""" + errors = [] + import re as _re + # 匹配开闭标签 + tag_pattern = _re.compile(r'<(/?)(\w+)[^>]*>') + void_tags = {'br', 'hr', 'img', 'input', 'meta', 'link', 'area', + 'base', 'col', 'embed', 'source', 'track', 'wbr'} + stack = [] + for m in tag_pattern.finditer(source): + is_close = m.group(1) == '/' + tag_name = m.group(2).lower() + if tag_name in void_tags: + continue + pos = m.start() + line_num = source[:pos].count('\n') + 1 + if is_close: + if stack and stack[-1][0] == tag_name: + stack.pop() + elif stack: + errors.append( + f" 第 {line_num} 行: 与 <{stack[-1][0]}> 不匹配" + ) + else: + stack.append((tag_name, line_num)) + for tag_name, line_num in stack: + errors.append(f" 第 {line_num} 行: <{tag_name}> 未闭合") + if errors and len(errors) <= 10: + return f" HTML 标签检查 ({filepath}):\n" + "\n".join(errors) + return "" + + def _check_xml_syntax(self, filepath: str, source: str) -> str: + """XML 语法检查:使用 xml.etree""" + import xml.etree.ElementTree as ET + try: + ET.fromstring(source) + except ET.ParseError as e: + return f" XML 语法检查失败 ({filepath}):\n {e}" + return "" + + def _check_css_syntax(self, filepath: str, source: str) -> str: + """CSS 基础检查:花括号匹配""" + errors = [] + depth = 0 + in_string = False + string_char = None + line_num = 1 + for i, ch in enumerate(source): + if ch == '\n': + line_num += 1 + continue + if in_string: + if ch == string_char and (i == 0 or source[i-1] != '\\'): + in_string = False + continue + if ch in ('"', "'"): + in_string = True + string_char = ch + continue + if ch == '{': + depth += 1 + elif ch == '}': + if depth > 0: + depth -= 1 + else: + errors.append(f" 第 {line_num} 行: 多余的闭合花括号 '}}'") + if depth > 0: + errors.append(f" 文件末尾: {depth} 个未闭合的花括号 '{{' ") + if errors: + return f" CSS 括号检查 ({filepath}):\n" + "\n".join(errors) + return "" + + def _check_shell_syntax(self, filepath: str, source: str) -> str: + """Shell 脚本语法检查:使用 bash -n""" + try: + result = subprocess.run( + ['bash', '-n', filepath], + capture_output=True, text=True, timeout=5 + ) + if result.returncode != 0: + stderr = result.stderr.strip() + lines = stderr.split('\n')[:5] + return f" Shell 语法检查失败 ({filepath}):\n" + "\n".join(f" {l}" for l in lines) + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return "" + + def _check_sql_syntax(self, filepath: str, source: str) -> str: + """SQL 基础检查:关键字匹配""" + # 简单检查未闭合的引号和括号 + errors = [] + single_q = double_q = 0 + paren_depth = 0 + line_num = 1 + for ch in source: + if ch == '\n': + line_num += 1 + continue + if ch == "'": + single_q += 1 + elif ch == '"': + double_q += 1 + elif ch == '(': + paren_depth += 1 + elif ch == ')': + paren_depth -= 1 + if single_q % 2 != 0: + errors.append(" 存在未闭合的单引号") + if double_q % 2 != 0: + errors.append(" 存在未闭合的双引号") + if paren_depth > 0: + errors.append(f" 存在 {paren_depth} 个未闭合的括号") + if errors: + return f" SQL 检查 ({filepath}):\n" + "\n".join(errors) + return "" + + def _auto_syntax_check(self, filepath: str) -> str: + """ + 编辑操作后自动语法检查。 + 返回格式化的结果字符串,供拼接到操作结果末尾。 + - 通过: 返回简短成功提示 + - 失败: 返回详细错误信息 + - 跳过: 返回空字符串 + """ + result = self._check_syntax(filepath) + if result is None: + return "" # 非代码文件,不检查 + if result == "": + return f"\n 语法检查通过 ✓" + else: + return f"\n{result}" + + # ══════════════════════════════════════ + # Diff 弹窗系统 + # ══════════════════════════════════════ + + def _read_file_safe(self, filepath: str) -> str: + """安全读取文件,失败返回空字符串""" + try: + with open(filepath, 'r', encoding='utf-8') as f: + return f.read() + except Exception: + return "" + + def _spawn_diff_popup(self, filepath: str, before: str, after: str): + """ + 在新终端窗口中显示修改前后的对比。 + 使用独立线程避免阻塞主流程。 + """ + if not self.diff_popup_enabled: + return + if before == after: + return # 内容无变化,不弹窗 + + thread = threading.Thread( + target=self._do_spawn_diff_popup, + args=(filepath, before, after), + daemon=True + ) + thread.start() + + def _do_spawn_diff_popup(self, filepath: str, before: str, after: str): + """实际执行弹窗创建(在子线程中运行)""" + basename = os.path.basename(filepath) + + # 生成带行号的 before/after + before_numbered = self._number_lines(before) + after_numbered = self._number_lines(after) + + # 生成 diff + diff_lines = list(difflib.unified_diff( + before.splitlines(keepends=True), + after.splitlines(keepends=True), + fromfile=f"修改前: {basename}", + tofile=f"修改后: {basename}", + lineterm='' + )) + diff_text = '\n'.join(diff_lines) + + # 统计变更 + added = sum(1 for l in diff_lines if l.startswith('+') and not l.startswith('+++')) + removed = sum(1 for l in diff_lines if l.startswith('-') and not l.startswith('---')) + + before_count = len(before.splitlines()) + after_count = len(after.splitlines()) + + # 构建弹窗显示内容 + display = self._build_diff_display( + basename, filepath, + before_numbered, after_numbered, + diff_text, added, removed, + before_count, after_count + ) + + # 写入临时脚本并弹出 + self._launch_popup_window(display, basename) + + def _number_lines(self, content: str) -> str: + """给内容加上行号""" + lines = content.splitlines() + width = len(str(len(lines))) + return '\n'.join(f"{i+1:>{width}} | {line}" for i, line in enumerate(lines)) + + def _build_diff_display(self, basename, filepath, + before_numbered, after_numbered, + diff_text, added, removed, + before_count, after_count): + """构建完整的 diff 显示文本""" + sep = '═' * 70 + thin_sep = '─' * 70 + + display = f""" +{sep} + 文件变更对比: {basename} + 路径: {filepath} +{sep} + + 统计: {before_count} 行 → {after_count} 行 | +{added} 新增 -{removed} 删除 + +{thin_sep} + 修改前 ({before_count} 行): +{thin_sep} +{before_numbered} + +{thin_sep} + 修改后 ({after_count} 行): +{thin_sep} +{after_numbered} + +{thin_sep} + 差异 (Diff): +{thin_sep} +{diff_text if diff_text else ' (无差异)'} + +{sep} + 按任意键关闭此窗口... +{sep} +""" + return display + + def _launch_popup_window(self, display_text: str, title: str): + """根据平台弹出新终端窗口""" + # 写入临时显示脚本 + try: + script_fd, script_path = tempfile.mkstemp(suffix='.py', prefix='xiaoli_diff_') + with os.fdopen(script_fd, 'w', encoding='utf-8') as f: + # 写入显示内容到脚本 + escaped = display_text.replace('\\', '\\\\').replace("'", "\\'") + f.write(f"#!/usr/bin/env python3\n") + f.write(f"# -*- coding: utf-8 -*-\n") + f.write(f"import sys\n") + f.write(f"text = '''{escaped}'''\n") + f.write(f"try:\n") + f.write(f" sys.stdout.reconfigure(encoding='utf-8')\n") + f.write(f"except Exception:\n") + f.write(f" pass\n") + f.write(f"print(text)\n") + f.write(f"try:\n") + f.write(f" input()\n") + f.write(f"except (EOFError, KeyboardInterrupt):\n") + f.write(f" pass\n") + + system = platform.system() + + if system == 'Windows': + # Windows: 新开 cmd 窗口 + subprocess.Popen( + ['cmd', '/c', 'start', 'cmd', '/k', sys.executable, script_path], + creationflags=subprocess.CREATE_NEW_CONSOLE, + shell=False + ) + elif system == 'Darwin': + # macOS: 用 osascript 打开 Terminal.app + apple_script = ( + f'tell application "Terminal"\n' + f' activate\n' + f' do script "{sys.executable} {script_path}"\n' + f'end tell' + ) + subprocess.Popen(['osascript', '-e', apple_script]) + else: + # Linux: 尝试多个终端模拟器 + terminals = [ + ['gnome-terminal', '--', sys.executable, script_path], + ['xterm', '-e', sys.executable, script_path], + ['konsole', '-e', sys.executable, script_path], + ['xfce4-terminal', '-e', f'{sys.executable} {script_path}'], + ['lxterminal', '-e', sys.executable, script_path], + ['mate-terminal', '-e', sys.executable, script_path], + ] + launched = False + for cmd in terminals: + try: + subprocess.Popen(cmd, start_new_session=True) + launched = True + break + except FileNotFoundError: + continue + if not launched: + # 兜底: 用 xdg-open 不太行,直接打印到当前终端 + print(f"\n[Diff 弹窗] 无可用终端模拟器,直接输出:\n") + print(display_text) + try: + os.unlink(script_path) + except Exception: + pass + + except Exception as e: + print(f"[Diff 弹窗] 创建失败: {e}") + + def _op_diff_popup(self, path: str, rest: str) -> str: + """切换 diff 弹窗开关""" + arg = (path + ' ' + rest).strip().lower() + if arg in ('on', '开', '开启', 'enable', '1', 'true'): + self.diff_popup_enabled = True + return " Diff 弹窗已开启" + elif arg in ('off', '关', '关闭', 'disable', '0', 'false'): + self.diff_popup_enabled = False + return " Diff 弹窗已关闭" + else: + # 无参数:切换状态 + self.diff_popup_enabled = not self.diff_popup_enabled + state = "开启" if self.diff_popup_enabled else "关闭" + return f" Diff 弹窗: {state}" + + def _op_syntax_check(self, path: str, rest: str) -> str: + """主动语法检查操作""" + if not path: + return "错误:请提供文件路径。用法: syntax_check <文件路径>" + fp = self._norm(path) + if not os.path.exists(fp): + return f"错误:文件不存在: {fp}" + + lang = self._get_lang(fp) + if lang is None: + return f"⏭ 跳过: {fp} (不支持的文件类型,无需语法检查)" + + result = self._check_syntax(fp) + if result is None: + return f"⏭ 跳过: {fp} (无法读取文件)" + if result == "": + return f" 语法检查通过 ✓ ({fp})\n 语言: {lang}" + else: + return result + # ══════════════════════════════════════ # 编辑操作 # ══════════════════════════════════════ @@ -221,11 +854,15 @@ class Plugin: if count > 1: return f"警告:找到 {count} 处匹配,请提供更多上下文以确保唯一。" + before_content = content # 保存修改前内容 new_content = content.replace(old_text, new_text, 1) with open(fp, 'w', encoding='utf-8') as f: f.write(new_content) - return f" 已编辑: {fp}\n\n{self._make_diff(old_text, new_text)}" + self._spawn_diff_popup(fp, before_content, new_content) + result = f" 已编辑: {fp}\n\n{self._make_diff(old_text, new_text)}" + result += self._auto_syntax_check(fp) + return result def _op_multi_edit(self, path: str, rest: str) -> str: import json @@ -245,6 +882,7 @@ class Plugin: with open(fp, 'r', encoding='utf-8') as f: content = f.read() + before_content = content # 保存修改前内容 results = [] for i, op in enumerate(ops): old = op.get("old", "").strip() @@ -259,7 +897,10 @@ class Plugin: with open(fp, 'w', encoding='utf-8') as f: f.write(content) - return f" 批量编辑: {fp}\n" + "\n".join(results) + self._spawn_diff_popup(fp, before_content, content) + result = f" 批量编辑: {fp}\n" + "\n".join(results) + result += self._auto_syntax_check(fp) + return result def _op_insert(self, path: str, rest: str) -> str: if not path: @@ -282,12 +923,17 @@ class Plugin: if line_num < 1 or line_num > len(lines) + 1: return f"错误:行号超出范围 (1-{len(lines)+1})" + before_content = ''.join(lines) # 保存修改前内容 insert_lines = [l + '\n' for l in parts[1].strip().split('\n')] lines[line_num-1:line_num-1] = insert_lines with open(fp, 'w', encoding='utf-8') as f: f.writelines(lines) - return f" 已在第 {line_num} 行插入 {len(insert_lines)} 行" + after_content = ''.join(lines) + self._spawn_diff_popup(fp, before_content, after_content) + result = f" 已在第 {line_num} 行插入 {len(insert_lines)} 行" + result += self._auto_syntax_check(fp) + return result def _op_delete_lines(self, path: str, rest: str) -> str: if not path: @@ -310,12 +956,17 @@ class Plugin: if start < 1 or end > len(lines) or start > end: return f"错误:行号范围无效 (1-{len(lines)})" + before_content = ''.join(lines) # 保存修改前内容 deleted = lines[start-1:end] lines[start-1:end] = [] with open(fp, 'w', encoding='utf-8') as f: f.writelines(lines) - return f" 已删除第 {start}-{end} 行 ({len(deleted)} 行)" + after_content = ''.join(lines) + self._spawn_diff_popup(fp, before_content, after_content) + result = f" 已删除第 {start}-{end} 行 ({len(deleted)} 行)" + result += self._auto_syntax_check(fp) + return result def _op_create(self, path: str, rest: str) -> str: if not path: @@ -328,7 +979,10 @@ class Plugin: os.makedirs(dir_path, exist_ok=True) with open(fp, 'w', encoding='utf-8') as f: f.write(rest or "") - return f" 已创建: {fp} ({len(rest or '')} 字符)" + self._spawn_diff_popup(fp, "", rest or "") + result = f" 已创建: {fp} ({len(rest or '')} 字符)" + result += self._auto_syntax_check(fp) + return result def _op_write(self, path: str, rest: str) -> str: if not path: @@ -337,9 +991,13 @@ class Plugin: dir_path = os.path.dirname(fp) if dir_path: os.makedirs(dir_path, exist_ok=True) + before_content = self._read_file_safe(fp) # 保存修改前内容 with open(fp, 'w', encoding='utf-8') as f: f.write(rest) - return f" 已写入: {fp} ({len(rest)} 字符)" + self._spawn_diff_popup(fp, before_content, rest) + result = f" 已写入: {fp} ({len(rest)} 字符)" + result += self._auto_syntax_check(fp) + return result def _op_append(self, path: str, rest: str) -> str: if not path: @@ -347,9 +1005,14 @@ class Plugin: fp = self._norm(path) if not os.path.exists(fp): return f"错误:文件不存在: {fp}" + before_content = self._read_file_safe(fp) # 保存修改前内容 with open(fp, 'a', encoding='utf-8') as f: f.write(rest) - return f" 已追加: {fp} ({len(rest)} 字符)" + after_content = self._read_file_safe(fp) + self._spawn_diff_popup(fp, before_content, after_content) + result = f" 已追加: {fp} ({len(rest)} 字符)" + result += self._auto_syntax_check(fp) + return result # ══════════════════════════════════════ # 查看操作 diff --git a/webui.html b/webui.html new file mode 100644 index 0000000..4bea0df --- /dev/null +++ b/webui.html @@ -0,0 +1,450 @@ + + + + + +小狸 Pro-CLI · Web UI + + + + +
+ +
+ +
+
对话
+
编辑器
+
+
+ gemma4:31b + 普通模式 + v5.1.2 +
+
+ + +
+
项目文件
+
+ ai_cli.py11行 +
+
+ web_ui.py1.1K +
+
+ { }config.json示例 +
+ +
+
xcli_core/
+
+ cli_core.py900行 +
+
safety.py292行
+
sandbox.py187行
+
memory.py记忆系统
+
tui.py899行
+
config.py配置
+ +
+
plugins/
+
code_editor.py23操作
+
git_tools.py20操作
+
browser_auto.py31操作
+
sub_agent.py8操作
+
auto_engineer.py16操作
+
file_manager.py11操作
+
cmd_executor.py1操作
+
task_manager.py7操作
+ +
+
ai_engines/
+
ollama_engine.py本地
+
openai_engine.py云端
+
+ + +
+ +
+
+
+

小狸 Pro-CLI v5.1.2

+

智能编程助手 — 代码编辑 · Git 集成 · 浏览器自动化 · 多 Agent 协作

+
+
查看项目结构
+
提交代码
+
代码统计
+
❓ 帮助
+
+
+
+
+
+ + +
+
自然语言交互 — AI 自动选择工具执行 · /help 查看命令 · /safe 切换安全模式
+
+
+ + +
+
+
+ 选择左侧文件开始编辑 + + +
+
+
1
+ +
+
+ 行 1, 列 1 + -- + UTF-8 + 就绪 +
+
+
+
+ + +
+
系统状态
+
+
15
插件
+
141
操作
+
2
引擎
+
113
测试
+
+ +
AI 引擎
+
▸ ollama (本地)●
+
openai (云端)○
+ +
⚡ 安全模式
+
普通模式●
+
人工确认○
+
无限制○
+ +
工具 (15)
+
+
code_editor23
+
git_tools20
+
browser_auto31
+
sub_agent8
+
auto_engineer16
+
file_manager11
+
cmd_executor1
+
task_manager7
+
network_tools4
+
ai_search2
+
tool_search1
+
frontend_tester5
+
speech_recognition2
+
audio_player3
+
send_image1
+
+ +
日志
+
+
[系统]01:04:22 Web UI 已启动
+
[引擎]01:04:22 Ollama 已连接
+
[安全]01:04:22 普通模式
+
[文件]01:04:22 已加载 15 个插件
+
+
+
+ + + + + diff --git a/xcli_core/cli_core.py b/xcli_core/cli_core.py index 74840e4..cb080ab 100644 --- a/xcli_core/cli_core.py +++ b/xcli_core/cli_core.py @@ -81,7 +81,7 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist """构建系统提示词 - 增强版,支持编程任务""" base_prompt = """你叫小狸,是一个强大的智能编程助手。你具备以下核心能力: -1. **精准代码编辑** - 使用 code_editor 工具进行搜索替换、多文件批量编辑 +1. **精准代码编辑** - 使用 code_editor 工具进行搜索替换、多文件批量编辑(修改后自动检查语法) 2. **代码搜索与理解** - 使用 code_search 工具跨文件搜索、提取符号、分析依赖 3. **Git 版本控制** - 使用 git_tools 工具管理代码版本 4. **Shell 命令执行** - 使用 cmd_executor 执行系统命令 @@ -161,8 +161,23 @@ class AICLI(BaseAICLI, ClawliMixin, ToolMixin, CodeExecMixin, DisplayMixin, Hist 2. 定位代码 → 已查询过,直接用 find 或 regex 搜索 3. 查看上下文 → 用 read_range 查看相关代码 4. 精准修改 → 用 edit 替换代码(old <<<>>> new 分隔) -5. 验证变更 → 先 tool_search "git_tools",再用 diff 查看变更 -6. 提交代码 → 已查询过,用 commit 提交 +5. 语法自动检查 → 编辑操作后自动执行,通过显示 ✓,失败显示详细错误 +6. 验证变更 → 先 tool_search "git_tools",再用 diff 查看变更 +7. 提交代码 → 已查询过,用 commit 提交 + +【语法检查 — 自动 + 主动】 +- 编辑操作(edit/multi/insert/delete_lines/create/write/append)完成后自动检查语法 +- 通过: 显示 " 语法检查通过 ✓" +- 失败: 显示具体错误(行号、列号、错误描述) +- 主动检查: syntax_check <文件路径> 或 check <文件路径> +- 支持语言: Python, JavaScript, TypeScript, JSON, YAML, TOML, HTML, XML, CSS, Shell, SQL +- 如果语法检查失败,根据错误信息修复代码后再次编辑 + +【Diff 弹窗 — 自动弹出修改对比】 +- 编辑操作完成后自动弹出新终端窗口,显示修改前后的完整内容和行号对比 +- 包含 diff 差异、新增/删除行数统计 +- 开关: diff_popup on(开启)/ diff_popup off(关闭)/ diff_popup(切换) +- 默认开启,无需手动调用 【code_editor 编辑格式】 edit 操作使用 <<<>>> 分隔旧代码和新代码: