feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
196
coworker/tools/search.py
Normal file
196
coworker/tools/search.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Fast code search (`grep`) — ripgrep when available, a Python walk otherwise.
|
||||
|
||||
ripgrep respects `.gitignore`, so it skips `node_modules`/`target`/`dist` automatically; the
|
||||
fallback skips a hardcoded set of heavy dirs. Read-only, workspace-scoped. Returns file:line:text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
# Per-OS application data directories. These are not build noise: on macOS 14+ merely
|
||||
# *descending* into ~/Library/Application Support (other apps' containers) trips the App
|
||||
# Data TCC protection and macOS shows "would like to access data from other apps" — an
|
||||
# alarming prompt the user never asked for, reachable whenever the workspace is a home
|
||||
# directory. Never traversed; a workspace under one of these is still searched normally,
|
||||
# because the guard matches directory NAMES encountered during a walk.
|
||||
OS_DATA_DIRS = {
|
||||
"Library", # macOS
|
||||
"AppData", # Windows
|
||||
"Application Data", # Windows (legacy junction)
|
||||
}
|
||||
|
||||
_IGNORE_DIRS = {
|
||||
".git",
|
||||
"node_modules",
|
||||
"target",
|
||||
"dist",
|
||||
"build",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
".next",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".idea",
|
||||
} | OS_DATA_DIRS
|
||||
|
||||
_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "grep",
|
||||
"description": (
|
||||
"Search the workspace for a regular-expression pattern and return matching lines as "
|
||||
"file:line:text. Fast and .gitignore-aware (skips node_modules, build dirs, etc.). "
|
||||
"Prefer this over reading files blindly to locate code. Read-only."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regular expression to search for.",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Subdirectory to search (default: whole workspace).",
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "Optional filename glob filter, e.g. '*.py'.",
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Max matches (default 100, max 1000).",
|
||||
},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def search_tools(workspace: str) -> list:
|
||||
root = Path(workspace).resolve()
|
||||
|
||||
def grep(
|
||||
pattern: str,
|
||||
path: str = ".",
|
||||
glob: Optional[str] = None,
|
||||
max_results: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
n = max_results if isinstance(max_results, int) and max_results > 0 else 100
|
||||
n = min(n, 1000)
|
||||
base = (root / (path or ".")).resolve()
|
||||
try:
|
||||
base.relative_to(root) # keep searches inside the workspace
|
||||
except ValueError:
|
||||
return {"error": "path escapes the workspace"}
|
||||
|
||||
rg = shutil.which("rg")
|
||||
if rg:
|
||||
cmd = [
|
||||
rg,
|
||||
"--line-number",
|
||||
"--no-heading",
|
||||
"--color=never",
|
||||
"--max-count",
|
||||
str(n),
|
||||
"-e",
|
||||
pattern,
|
||||
]
|
||||
if glob:
|
||||
cmd += ["--glob", glob]
|
||||
# Do not rely solely on a workspace's .gitignore: the Python fallback
|
||||
# always omits these generated/dependency directories too. Exclusions come
|
||||
# last because ripgrep resolves conflicting globs with the later one winning.
|
||||
for ignored in sorted(_IGNORE_DIRS):
|
||||
cmd += ["--glob", f"!**/{ignored}/**"]
|
||||
cmd.append(str(base))
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
except Exception as exc:
|
||||
return {"error": f"grep failed: {exc}"}
|
||||
if out.returncode not in (0, 1): # 1 = no matches
|
||||
return {"error": (out.stderr or "ripgrep error").strip()[:300]}
|
||||
return {"engine": "ripgrep", **_parse_rg(out.stdout, root, n)}
|
||||
|
||||
return {"engine": "python", **_py_grep(root, base, pattern, glob, n)}
|
||||
|
||||
grep.__name__ = "grep"
|
||||
grep.__doc__ = _SCHEMA["function"]["description"]
|
||||
grep.__aisuite_tool_metadata__ = ai.ToolMetadata(
|
||||
name="grep",
|
||||
category="search",
|
||||
risk_level="low",
|
||||
capabilities=["search"],
|
||||
requires_approval=False,
|
||||
)
|
||||
grep.__coworker_schema__ = _SCHEMA
|
||||
return [grep]
|
||||
|
||||
|
||||
def _rel(path: str, root: Path) -> str:
|
||||
try:
|
||||
return str(Path(path).resolve().relative_to(root))
|
||||
except (ValueError, OSError):
|
||||
return path
|
||||
|
||||
|
||||
def _parse_rg(stdout: str, root: Path, n: int) -> dict[str, Any]:
|
||||
matches: list[dict[str, Any]] = []
|
||||
for line in stdout.splitlines():
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) == 3:
|
||||
f, ln, txt = parts
|
||||
matches.append(
|
||||
{
|
||||
"file": _rel(f, root),
|
||||
"line": int(ln) if ln.isdigit() else 0,
|
||||
"text": txt[:300],
|
||||
}
|
||||
)
|
||||
if len(matches) >= n:
|
||||
break
|
||||
return {"count": len(matches), "matches": matches}
|
||||
|
||||
|
||||
def _py_grep(
|
||||
root: Path, base: Path, pattern: str, glob: Optional[str], n: int
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
rx = re.compile(pattern)
|
||||
except re.error as exc:
|
||||
return {"error": f"invalid regex: {exc}", "count": 0, "matches": []}
|
||||
matches: list[dict[str, Any]] = []
|
||||
for dirpath, dirs, files in os.walk(base):
|
||||
dirs[:] = [d for d in dirs if d not in _IGNORE_DIRS]
|
||||
for fn in files:
|
||||
if glob and not fnmatch.fnmatch(fn, glob):
|
||||
continue
|
||||
fp = Path(dirpath) / fn
|
||||
try:
|
||||
with open(fp, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
for i, line in enumerate(fh, 1):
|
||||
if rx.search(line):
|
||||
matches.append(
|
||||
{
|
||||
"file": _rel(str(fp), root),
|
||||
"line": i,
|
||||
"text": line.rstrip()[:300],
|
||||
}
|
||||
)
|
||||
if len(matches) >= n:
|
||||
return {"count": len(matches), "matches": matches}
|
||||
except OSError:
|
||||
continue
|
||||
return {"count": len(matches), "matches": matches}
|
||||
Reference in New Issue
Block a user