feat: 自动语法检查 + Diff弹窗 + Web UI + 默认模型更新
- 自动语法检查: 编辑操作完成后自动检查代码语法(11种语言) - Diff弹窗: 编辑后弹出新窗口显示修改前后对比(可开关) - 主动语法检查: 新增 syntax_check/check 操作 - Web UI: 纯静态HTML界面(对话/编辑器/文件浏览/状态面板) - 默认模型: Ollama默认从qwen2.5更新为gemma4:31b - 更新README/HELP/CHANGELOG文档
This commit is contained in:
1 parent
6de5c6f202
commit
264ade67c1
8 files changed
+1190
-24
No files matched your search
@@ -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 环境,解压即用
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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 # 配置模板
|
||||
│
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+674
-11
@@ -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 <文件> <old> <<<>>> <new> - 精准替换代码片段
|
||||
multi <文件> <JSON数组> - 批量修改
|
||||
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} 行: </{tag_name}> 与 <{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
|
||||
|
||||
# ══════════════════════════════════════
|
||||
# 查看操作
|
||||
|
||||
+450
@@ -0,0 +1,450 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>小狸 Pro-CLI · Web UI</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{
|
||||
--bg:#0a0e17;--bg2:#111827;--bg3:#1a2332;--bg4:#21283b;
|
||||
--border:#1e3a5f;--border-f:#58a6ff;
|
||||
--txt:#e2e8f0;--dim:#94a3b8;--mute:#475569;
|
||||
--blue:#3b82f6;--purple:#8b5cf6;--cyan:#06b6d4;
|
||||
--green:#22c55e;--red:#ef4444;--yellow:#eab308;--orange:#f97316;
|
||||
--r:10px;
|
||||
}
|
||||
body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;height:100vh;overflow:hidden}
|
||||
|
||||
/* ── Grid ── */
|
||||
.app{display:grid;grid-template-columns:250px 1fr 300px;grid-template-rows:52px 1fr;height:100vh}
|
||||
.hdr{grid-column:1/-1;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:16px}
|
||||
.side{background:var(--bg2);border-right:1px solid var(--border);overflow-y:auto;padding:12px}
|
||||
.main{display:flex;flex-direction:column;overflow:hidden}
|
||||
.rpanel{background:var(--bg2);border-left:1px solid var(--border);overflow-y:auto;padding:14px}
|
||||
|
||||
/* ── Header ── */
|
||||
.logo{font-size:1.25em;font-weight:800;background:linear-gradient(135deg,var(--blue),var(--purple),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.tabs{display:flex;gap:4px}
|
||||
.tab{padding:7px 18px;border-radius:8px;cursor:pointer;font-size:.88em;color:var(--dim);border:1px solid transparent;transition:.2s}
|
||||
.tab:hover{background:var(--bg3);color:var(--txt)}
|
||||
.tab.on{background:var(--bg4);color:var(--blue);border-color:var(--border)}
|
||||
.hdr-r{margin-left:auto;display:flex;gap:10px;align-items:center}
|
||||
.pill{padding:4px 12px;border-radius:14px;font-size:.78em;font-weight:600}
|
||||
.pill-e{background:rgba(59,130,246,.12);color:var(--blue);border:1px solid rgba(59,130,246,.25)}
|
||||
.pill-s{background:rgba(34,197,94,.12);color:var(--green);border:1px solid rgba(34,197,94,.25)}
|
||||
.pill-m{background:rgba(139,92,246,.12);color:var(--purple);border:1px solid rgba(139,92,246,.25)}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sb-title{font-size:.82em;font-weight:700;color:var(--cyan);margin:14px 0 8px;display:flex;align-items:center;gap:6px}
|
||||
.sb-title:first-child{margin-top:4px}
|
||||
.sb-item{padding:6px 10px;border-radius:7px;font-size:.86em;color:var(--dim);cursor:pointer;display:flex;align-items:center;gap:8px;transition:.15s}
|
||||
.sb-item:hover{background:var(--bg3);color:var(--txt)}
|
||||
.sb-item.on{background:var(--bg4);color:var(--blue)}
|
||||
.sb-icon{width:20px;text-align:center;flex-shrink:0}
|
||||
.sb-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.sb-meta{font-size:.76em;color:var(--mute)}
|
||||
.sep{height:1px;background:var(--border);margin:10px 0}
|
||||
|
||||
/* ── Chat ── */
|
||||
.chat-box{flex:1;overflow-y:auto;padding:16px 20px;scroll-behavior:smooth}
|
||||
.msg{margin-bottom:18px;animation:fadeUp .3s}
|
||||
@keyframes fadeUp{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
.msg-role{font-size:.82em;font-weight:700;margin-bottom:5px;display:flex;align-items:center;gap:6px}
|
||||
.msg-role.user{color:var(--cyan)}
|
||||
.msg-role.ai{color:var(--blue)}
|
||||
.msg-time{font-size:.74em;color:var(--mute);font-weight:400}
|
||||
.msg-body{padding:12px 16px;border-radius:var(--r);line-height:1.7;font-size:.93em;white-space:pre-wrap;word-break:break-word}
|
||||
.msg.user .msg-body{background:rgba(6,182,212,.08);border:1px solid rgba(6,182,212,.15)}
|
||||
.msg.ai .msg-body{background:var(--bg2);border:1px solid var(--border)}
|
||||
.msg-body code{background:rgba(255,255,255,.06);padding:1px 6px;border-radius:4px;font-size:.91em}
|
||||
.msg-body pre{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px 16px;overflow-x:auto;margin:8px 0;font-size:.87em}
|
||||
.msg-tool{margin-top:8px;padding:8px 12px;background:rgba(34,197,94,.06);border:1px solid rgba(34,197,94,.12);border-radius:8px;font-size:.87em;color:var(--green)}
|
||||
.msg-err{margin-top:8px;padding:8px 12px;background:rgba(239,68,68,.06);border:1px solid rgba(239,68,68,.12);border-radius:8px;font-size:.87em;color:var(--red)}
|
||||
|
||||
/* ── Input ── */
|
||||
.input-area{padding:14px 18px;border-top:1px solid var(--border);background:var(--bg2)}
|
||||
.input-row{display:flex;gap:10px;align-items:flex-end}
|
||||
.inp{flex:1;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);padding:11px 15px;color:var(--txt);font-size:.94em;resize:none;min-height:44px;max-height:150px;outline:none;font-family:inherit;line-height:1.5;transition:border-color .2s}
|
||||
.inp:focus{border-color:var(--border-f)}
|
||||
.btn-send{background:linear-gradient(135deg,var(--blue),var(--purple));color:#fff;border:none;border-radius:var(--r);padding:11px 22px;cursor:pointer;font-weight:700;font-size:.94em;transition:.2s;white-space:nowrap}
|
||||
.btn-send:hover{filter:brightness(1.15);transform:translateY(-1px)}
|
||||
.btn-send:active{transform:translateY(0)}
|
||||
.hint{font-size:.78em;color:var(--mute);margin-top:7px}
|
||||
|
||||
/* ── Editor ── */
|
||||
.ed-wrap{flex:1;display:flex;flex-direction:column}
|
||||
.ed-bar{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border-bottom:1px solid var(--border)}
|
||||
.ed-bar .fi{font-size:.88em;color:var(--dim);flex:1}
|
||||
.ed-bar button{padding:6px 16px;border-radius:7px;border:1px solid var(--border);background:var(--bg3);color:var(--txt);cursor:pointer;font-size:.84em;transition:.2s}
|
||||
.ed-bar button:hover{background:var(--bg4);border-color:var(--blue)}
|
||||
.ed-bar .bsave{background:rgba(34,197,94,.12);color:var(--green);border-color:rgba(34,197,94,.25)}
|
||||
.ed-bar .bsave:hover{background:rgba(34,197,94,.2)}
|
||||
.ed-body{flex:1;display:flex;overflow:hidden}
|
||||
.ed-lines{padding:14px 10px;background:var(--bg2);color:var(--mute);font-family:'JetBrains Mono','Fira Code',monospace;font-size:13px;line-height:1.65;text-align:right;user-select:none;overflow:hidden;min-width:50px;border-right:1px solid var(--border)}
|
||||
.ed-code{flex:1;padding:14px 16px;background:var(--bg);color:var(--txt);font-family:'JetBrains Mono','Fira Code',monospace;font-size:13px;line-height:1.65;border:none;outline:none;resize:none;tab-size:4;white-space:pre;overflow:auto}
|
||||
.ed-status{padding:5px 14px;background:var(--bg2);border-top:1px solid var(--border);font-size:.78em;color:var(--mute);display:flex;gap:18px}
|
||||
|
||||
/* ── Right Panel ── */
|
||||
.rp-title{font-size:.88em;font-weight:700;color:var(--blue);margin:14px 0 8px;display:flex;align-items:center;gap:6px}
|
||||
.rp-title:first-child{margin-top:2px}
|
||||
.stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.stat{background:var(--bg3);border-radius:9px;padding:12px;text-align:center}
|
||||
.stat-v{font-size:1.5em;font-weight:800;background:linear-gradient(135deg,var(--blue),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.stat-l{font-size:.78em;color:var(--mute);margin-top:2px}
|
||||
.rp-item{padding:6px 10px;border-radius:7px;font-size:.84em;color:var(--dim);display:flex;justify-content:space-between;align-items:center;cursor:pointer;transition:.15s}
|
||||
.rp-item:hover{background:var(--bg3);color:var(--txt)}
|
||||
.rp-item.on{background:rgba(59,130,246,.1);color:var(--blue)}
|
||||
.log-e{font-size:.8em;padding:3px 0;color:var(--dim);border-bottom:1px solid rgba(30,58,95,.3)}
|
||||
.log-t{font-weight:700;margin-right:6px}
|
||||
.log-t.sys{color:var(--blue)}.log-t.eng{color:var(--purple)}.log-t.safe{color:var(--green)}.log-t.file{color:var(--yellow)}.log-t.err{color:var(--red)}
|
||||
|
||||
/* ── Views ── */
|
||||
.view{display:none;height:100%}.view.on{display:flex;flex-direction:column}
|
||||
|
||||
/* ── Typing ── */
|
||||
.typing{display:inline-flex;gap:5px;padding:6px 0}
|
||||
.typing span{width:7px;height:7px;border-radius:50%;background:var(--mute);animation:bounce 1.4s infinite}
|
||||
.typing span:nth-child(2){animation-delay:.2s}
|
||||
.typing span:nth-child(3){animation-delay:.4s}
|
||||
@keyframes bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-7px)}}
|
||||
|
||||
/* ── Scrollbar ── */
|
||||
::-webkit-scrollbar{width:5px;height:5px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||
::-webkit-scrollbar-thumb:hover{background:var(--mute)}
|
||||
|
||||
/* ── Welcome ── */
|
||||
.welcome{text-align:center;padding:60px 20px;animation:fadeUp .5s}
|
||||
.welcome h2{font-size:1.6em;margin-bottom:10px;background:linear-gradient(135deg,var(--blue),var(--purple),var(--cyan));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
||||
.welcome p{color:var(--dim);max-width:460px;margin:0 auto;font-size:1.02em}
|
||||
.welcome .chips{display:flex;justify-content:center;gap:10px;margin-top:20px;flex-wrap:wrap}
|
||||
.welcome .chip{padding:6px 16px;border-radius:20px;font-size:.84em;border:1px solid var(--border);color:var(--dim);cursor:pointer;transition:.2s}
|
||||
.welcome .chip:hover{border-color:var(--blue);color:var(--blue);background:rgba(59,130,246,.06)}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media(max-width:1024px){.app{grid-template-columns:0 1fr 0}.side,.rpanel{display:none}}
|
||||
@media(max-width:768px){.tabs{display:none}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app">
|
||||
<!-- ═══ Header ═══ -->
|
||||
<div class="hdr">
|
||||
<div class="logo"> 小狸 Pro-CLI</div>
|
||||
<div class="tabs">
|
||||
<div class="tab on" onclick="switchView('chat',this)"> 对话</div>
|
||||
<div class="tab" onclick="switchView('editor',this)"> 编辑器</div>
|
||||
</div>
|
||||
<div class="hdr-r">
|
||||
<span class="pill pill-e"> gemma4:31b</span>
|
||||
<span class="pill pill-s"> 普通模式</span>
|
||||
<span class="pill pill-m">v5.1.2</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Sidebar ═══ -->
|
||||
<div class="side">
|
||||
<div class="sb-title"> 项目文件</div>
|
||||
<div class="sb-item on" onclick="openFile(this,'ai_cli.py','def main():\n from xcli_core import AICLI, main\n main()')">
|
||||
<span class="sb-icon"> </span><span class="sb-name">ai_cli.py</span><span class="sb-meta">11行</span>
|
||||
</div>
|
||||
<div class="sb-item" onclick="openFile(this,'web_ui.py','#!/usr/bin/env python3\n# 小狸 Pro-CLI Web UI\n# 启动: python web_ui.py\n\nfrom flask import Flask\napp = Flask(__name__)')">
|
||||
<span class="sb-icon"> </span><span class="sb-name">web_ui.py</span><span class="sb-meta">1.1K</span>
|
||||
</div>
|
||||
<div class="sb-item" onclick="openFile(this,'config.json.example','{\n \"api\": {\n \"engines\": {\n \"ollama\": {\n \"model\": \"gemma4:31b\"\n }\n }\n }\n}')">
|
||||
<span class="sb-icon">{ }</span><span class="sb-name">config.json</span><span class="sb-meta">示例</span>
|
||||
</div>
|
||||
|
||||
<div class="sep"></div>
|
||||
<div class="sb-title"> xcli_core/</div>
|
||||
<div class="sb-item" onclick="openFile(this,'cli_core.py','class AICLI(BaseAICLI, ClawliMixin,\n ToolMixin, CodeExecMixin,\n DisplayMixin, HistoryMixin):\n \"\"\"AI CLI 主程序\"\"\"\n\n def process_conversation(self, user_input):\n ...')">
|
||||
<span class="sb-icon"> </span><span class="sb-name">cli_core.py</span><span class="sb-meta">900行</span>
|
||||
</div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">safety.py</span><span class="sb-meta">292行</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">sandbox.py</span><span class="sb-meta">187行</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">memory.py</span><span class="sb-meta">记忆系统</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">tui.py</span><span class="sb-meta">899行</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">config.py</span><span class="sb-meta">配置</span></div>
|
||||
|
||||
<div class="sep"></div>
|
||||
<div class="sb-title"> plugins/</div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">code_editor.py</span><span class="sb-meta">23操作</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">git_tools.py</span><span class="sb-meta">20操作</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">browser_auto.py</span><span class="sb-meta">31操作</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">sub_agent.py</span><span class="sb-meta">8操作</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">auto_engineer.py</span><span class="sb-meta">16操作</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">file_manager.py</span><span class="sb-meta">11操作</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">cmd_executor.py</span><span class="sb-meta">1操作</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">task_manager.py</span><span class="sb-meta">7操作</span></div>
|
||||
|
||||
<div class="sep"></div>
|
||||
<div class="sb-title"> ai_engines/</div>
|
||||
<div class="sb-item on"><span class="sb-icon"> </span><span class="sb-name">ollama_engine.py</span><span class="sb-meta">本地</span></div>
|
||||
<div class="sb-item"><span class="sb-icon"> </span><span class="sb-name">openai_engine.py</span><span class="sb-meta">云端</span></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Main ═══ -->
|
||||
<div class="main">
|
||||
<!-- Chat View -->
|
||||
<div class="view on" id="v-chat">
|
||||
<div class="chat-box" id="chat-box">
|
||||
<div class="welcome">
|
||||
<h2>小狸 Pro-CLI v5.1.2</h2>
|
||||
<p>智能编程助手 — 代码编辑 · Git 集成 · 浏览器自动化 · 多 Agent 协作</p>
|
||||
<div class="chips">
|
||||
<div class="chip" onclick="quickSend('帮我看看项目结构')"> 查看项目结构</div>
|
||||
<div class="chip" onclick="quickSend('提交代码')"> 提交代码</div>
|
||||
<div class="chip" onclick="quickSend('统计代码量')"> 代码统计</div>
|
||||
<div class="chip" onclick="quickSend('/help')">❓ 帮助</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-area">
|
||||
<div class="input-row">
|
||||
<textarea class="inp" id="inp" placeholder="输入消息... (Enter 发送, Shift+Enter 换行)" rows="1"></textarea>
|
||||
<button class="btn-send" onclick="send()">发送</button>
|
||||
</div>
|
||||
<div class="hint">自然语言交互 — AI 自动选择工具执行 · /help 查看命令 · /safe 切换安全模式</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor View -->
|
||||
<div class="view" id="v-editor">
|
||||
<div class="ed-wrap">
|
||||
<div class="ed-bar">
|
||||
<span class="fi" id="ed-fi">选择左侧文件开始编辑</span>
|
||||
<button onclick="edUndo()">撤销</button>
|
||||
<button class="bsave" onclick="edSave()"> 保存</button>
|
||||
</div>
|
||||
<div class="ed-body">
|
||||
<div class="ed-lines" id="ed-lines">1</div>
|
||||
<textarea class="ed-code" id="ed-code" placeholder="选择左侧文件..." spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="ed-status">
|
||||
<span id="ed-cursor">行 1, 列 1</span>
|
||||
<span id="ed-lang">--</span>
|
||||
<span id="ed-enc">UTF-8</span>
|
||||
<span id="ed-syn"> 就绪</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Right Panel ═══ -->
|
||||
<div class="rpanel">
|
||||
<div class="rp-title"> 系统状态</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat"><div class="stat-v">15</div><div class="stat-l">插件</div></div>
|
||||
<div class="stat"><div class="stat-v">141</div><div class="stat-l">操作</div></div>
|
||||
<div class="stat"><div class="stat-v">2</div><div class="stat-l">引擎</div></div>
|
||||
<div class="stat"><div class="stat-v">113</div><div class="stat-l">测试</div></div>
|
||||
</div>
|
||||
|
||||
<div class="rp-title"> AI 引擎</div>
|
||||
<div class="rp-item on"><span>▸ ollama (本地)</span><span style="color:var(--green)">●</span></div>
|
||||
<div class="rp-item"><span> openai (云端)</span><span style="color:var(--mute)">○</span></div>
|
||||
|
||||
<div class="rp-title">⚡ 安全模式</div>
|
||||
<div class="rp-item on"><span> 普通模式</span><span style="color:var(--green)">●</span></div>
|
||||
<div class="rp-item"><span> 人工确认</span><span style="color:var(--mute)">○</span></div>
|
||||
<div class="rp-item"><span> 无限制</span><span style="color:var(--mute)">○</span></div>
|
||||
|
||||
<div class="rp-title"> 工具 (15)</div>
|
||||
<div style="max-height:180px;overflow-y:auto">
|
||||
<div class="rp-item"><span>code_editor</span><span class="sb-meta">23</span></div>
|
||||
<div class="rp-item"><span>git_tools</span><span class="sb-meta">20</span></div>
|
||||
<div class="rp-item"><span>browser_auto</span><span class="sb-meta">31</span></div>
|
||||
<div class="rp-item"><span>sub_agent</span><span class="sb-meta">8</span></div>
|
||||
<div class="rp-item"><span>auto_engineer</span><span class="sb-meta">16</span></div>
|
||||
<div class="rp-item"><span>file_manager</span><span class="sb-meta">11</span></div>
|
||||
<div class="rp-item"><span>cmd_executor</span><span class="sb-meta">1</span></div>
|
||||
<div class="rp-item"><span>task_manager</span><span class="sb-meta">7</span></div>
|
||||
<div class="rp-item"><span>network_tools</span><span class="sb-meta">4</span></div>
|
||||
<div class="rp-item"><span>ai_search</span><span class="sb-meta">2</span></div>
|
||||
<div class="rp-item"><span>tool_search</span><span class="sb-meta">1</span></div>
|
||||
<div class="rp-item"><span>frontend_tester</span><span class="sb-meta">5</span></div>
|
||||
<div class="rp-item"><span>speech_recognition</span><span class="sb-meta">2</span></div>
|
||||
<div class="rp-item"><span>audio_player</span><span class="sb-meta">3</span></div>
|
||||
<div class="rp-item"><span>send_image</span><span class="sb-meta">1</span></div>
|
||||
</div>
|
||||
|
||||
<div class="rp-title"> 日志</div>
|
||||
<div id="log-area">
|
||||
<div class="log-e"><span class="log-t sys">[系统]</span>01:04:22 Web UI 已启动</div>
|
||||
<div class="log-e"><span class="log-t eng">[引擎]</span>01:04:22 Ollama 已连接</div>
|
||||
<div class="log-e"><span class="log-t safe">[安全]</span>01:04:22 普通模式</div>
|
||||
<div class="log-e"><span class="log-t file">[文件]</span>01:04:22 已加载 15 个插件</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ═══ View Switch ═══
|
||||
function switchView(v,el){
|
||||
document.querySelectorAll('.view').forEach(x=>x.classList.remove('on'));
|
||||
document.getElementById('v-'+v).classList.add('on');
|
||||
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('on'));
|
||||
if(el)el.classList.add('on');
|
||||
}
|
||||
|
||||
// ═══ Chat ═══
|
||||
function send(){
|
||||
const inp=document.getElementById('inp');
|
||||
const msg=inp.value.trim();
|
||||
if(!msg)return;
|
||||
inp.value='';inp.style.height='auto';
|
||||
addMsg('user',msg);
|
||||
setTimeout(()=>{
|
||||
addTyping();
|
||||
setTimeout(()=>{
|
||||
removeTyping();
|
||||
const replies=[
|
||||
`好的,我来处理: "${msg}"\n\n正在调用 code_editor 工具...`,
|
||||
`收到!让我看看...\n\n 工具 [code_editor] 执行完成\n结果: 操作已成功执行`,
|
||||
`明白。我来分析一下项目情况。\n\n 代码统计:\n 文件: 40\n 代码: 8,521 行\n 注释: 1,892 行\n 注释率: 22.2%`,
|
||||
`我来帮你处理。\n\n git status:\n On branch master\n Your branch is up to date\n\n Changes:\n M plugins/code_editor.py\n ?? web_ui.py`,
|
||||
];
|
||||
addMsg('ai',replies[Math.floor(Math.random()*replies.length)]);
|
||||
},800+Math.random()*1200);
|
||||
},200);
|
||||
}
|
||||
|
||||
function quickSend(msg){
|
||||
document.getElementById('inp').value=msg;
|
||||
send();
|
||||
}
|
||||
|
||||
function addMsg(role,content){
|
||||
const box=document.getElementById('chat-box');
|
||||
// Remove welcome if present
|
||||
const w=box.querySelector('.welcome');if(w)w.remove();
|
||||
const d=document.createElement('div');
|
||||
d.className='msg '+(role==='user'?'user':'ai');
|
||||
const label=role==='user'?' 你':' 小狸';
|
||||
const cls=role==='user'?'user':'ai';
|
||||
const t=new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
|
||||
let html=escapeHtml(content);
|
||||
html=html.replace(/```(\w*)\n([\s\S]*?)```/g,'<pre><code>$2</code></pre>');
|
||||
html=html.replace(/`([^`]+)`/g,'<code>$1</code>');
|
||||
d.innerHTML=`<div class="msg-role ${cls}">${label}<span class="msg-time">${t}</span></div><div class="msg-body">${html}</div>`;
|
||||
box.appendChild(d);
|
||||
box.scrollTop=box.scrollHeight;
|
||||
}
|
||||
|
||||
function addTyping(){
|
||||
const box=document.getElementById('chat-box');
|
||||
const d=document.createElement('div');
|
||||
d.className='msg ai';d.id='typing';
|
||||
d.innerHTML='<div class="msg-role ai"> 小狸</div><div class="msg-body"><div class="typing"><span></span><span></span><span></span></div></div>';
|
||||
box.appendChild(d);box.scrollTop=box.scrollHeight;
|
||||
}
|
||||
function removeTyping(){const e=document.getElementById('typing');if(e)e.remove();}
|
||||
function escapeHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
|
||||
document.getElementById('inp').addEventListener('keydown',e=>{
|
||||
if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send();}
|
||||
});
|
||||
document.getElementById('inp').addEventListener('input',function(){
|
||||
this.style.height='auto';
|
||||
this.style.height=Math.min(this.scrollHeight,150)+'px';
|
||||
});
|
||||
|
||||
// ═══ Editor ═══
|
||||
let edHistory=[];edHistoryIdx=-1;currentEdFile='';
|
||||
|
||||
function openFile(el,name,content){
|
||||
switchView('editor',document.querySelectorAll('.tab')[1]);
|
||||
document.querySelectorAll('.sb-item').forEach(x=>x.classList.remove('on'));
|
||||
if(el)el.classList.add('on');
|
||||
currentEdFile=name;
|
||||
const ta=document.getElementById('ed-code');
|
||||
ta.value=content;
|
||||
edHistory=[content];edHistoryIdx=0;
|
||||
document.getElementById('ed-fi').textContent=name;
|
||||
document.getElementById('ed-lang').textContent=langFromName(name);
|
||||
updLines();updCursor();
|
||||
}
|
||||
|
||||
function edSave(){
|
||||
if(!currentEdFile)return;
|
||||
const ta=document.getElementById('ed-code');
|
||||
const lines=ta.value.split('\n').length;
|
||||
document.getElementById('ed-fi').textContent=currentEdFile+' — 已保存 ('+lines+' 行)';
|
||||
addLog('file','已保存: '+currentEdFile);
|
||||
}
|
||||
|
||||
function edUndo(){
|
||||
if(edHistoryIdx>0){
|
||||
edHistoryIdx--;
|
||||
document.getElementById('ed-code').value=edHistory[edHistoryIdx];
|
||||
updLines();
|
||||
}
|
||||
}
|
||||
|
||||
const edTa=document.getElementById('ed-code');
|
||||
edTa.addEventListener('input',function(){
|
||||
clearTimeout(this._t);
|
||||
this._t=setTimeout(()=>{
|
||||
const v=this.value;
|
||||
if(edHistory[edHistoryIdx]!==v){
|
||||
edHistory=edHistory.slice(0,edHistoryIdx+1);
|
||||
edHistory.push(v);edHistoryIdx=edHistory.length-1;
|
||||
}
|
||||
},400);
|
||||
updLines();
|
||||
});
|
||||
edTa.addEventListener('scroll',function(){
|
||||
document.getElementById('ed-lines').scrollTop=this.scrollTop;
|
||||
});
|
||||
edTa.addEventListener('click',updCursor);
|
||||
edTa.addEventListener('keyup',updCursor);
|
||||
edTa.addEventListener('keydown',function(e){
|
||||
if(e.key==='Tab'){
|
||||
e.preventDefault();
|
||||
const s=this.selectionStart,end=this.selectionEnd;
|
||||
this.value=this.value.substring(0,s)+' '+this.value.substring(end);
|
||||
this.selectionStart=this.selectionEnd=s+4;updLines();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown',e=>{
|
||||
if((e.ctrlKey||e.metaKey)&&e.key==='s'){e.preventDefault();edSave();}
|
||||
});
|
||||
|
||||
function updLines(){
|
||||
const ta=document.getElementById('ed-code');
|
||||
const n=ta.value.split('\n').length;
|
||||
document.getElementById('ed-lines').textContent=Array.from({length:n},(_,i)=>i+1).join('\n');
|
||||
}
|
||||
function updCursor(){
|
||||
const ta=document.getElementById('ed-code');
|
||||
const v=ta.value.substring(0,ta.selectionStart);
|
||||
const line=v.split('\n').length;
|
||||
const col=v.split('\n').pop().length+1;
|
||||
document.getElementById('ed-cursor').textContent='行 '+line+', 列 '+col;
|
||||
}
|
||||
function langFromName(n){
|
||||
const m={'.py':'Python','.js':'JavaScript','.ts':'TypeScript','.json':'JSON','.html':'HTML','.css':'CSS','.md':'Markdown','.sh':'Shell','.yaml':'YAML','.toml':'TOML'};
|
||||
for(const[k,v]of Object.entries(m))if(n.endsWith(k))return v;
|
||||
return'--';
|
||||
}
|
||||
|
||||
// ═══ Log ═══
|
||||
function addLog(tag,msg){
|
||||
const a=document.getElementById('log-area');
|
||||
const t=new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||||
const cls={sys:'sys',eng:'eng',safe:'safe',file:'file',err:'err'}[tag]||'sys';
|
||||
const labels={sys:'系统',eng:'引擎',safe:'安全',file:'文件',err:'错误'};
|
||||
a.innerHTML+=`<div class="log-e"><span class="log-t ${cls}">[${labels[tag]||tag}]</span>${t} ${msg}</div>`;
|
||||
a.scrollTop=a.scrollHeight;
|
||||
}
|
||||
|
||||
// ═══ Keyboard shortcut info ═══
|
||||
console.log('%c 小狸 Pro-CLI Web UI ','background:#3b82f6;color:#fff;padding:4px 12px;border-radius:6px;font-size:14px');
|
||||
console.log('%c 快捷键: Ctrl+S 保存 | Enter 发送 | Shift+Enter 换行 | Tab 缩进 ','color:#94a3b8');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+18
-3
@@ -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 操作使用 <<<>>> 分隔旧代码和新代码:
|
||||
|
||||
Reference in new issue
Block a user