Add Feishu online document conversion
This commit is contained in:
@@ -38,6 +38,8 @@ from src.agent_types import (
|
||||
ModelConfig,
|
||||
)
|
||||
from src.bundled_skills import ALWAYS_ENABLED_HIDDEN_SKILL_NAMES, get_bundled_skills
|
||||
from src.data_agent_inputs import DataAgentInputError, load_input_sources
|
||||
from src.mcp_runtime import MCPRuntime, MCPServerProfile
|
||||
from src.openai_compat import OpenAICompatClient, OpenAICompatError
|
||||
from src.session_store import (
|
||||
DEFAULT_AGENT_SESSION_DIR,
|
||||
@@ -53,6 +55,27 @@ from src.token_budget import calculate_token_budget
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static'
|
||||
API_TOOL_CONTENT_MAX_CHARS = 20000
|
||||
FEISHU_NPM_REGISTRY = 'https://pkgs.d.xiaomi.net/artifactory/api/npm/mi-npm/'
|
||||
FEISHU_MCP_PACKAGE = '@mi/feishu-mcp-pro@latest'
|
||||
FEISHU_MCP_SERVER_NAME = 'feishu-mcp-pro'
|
||||
FEISHU_SUPPORTED_DOCUMENT_SUFFIXES = {'.csv', '.docx', '.md', '.txt', '.xlsx'}
|
||||
FEISHU_UNSUPPORTED_DOCUMENT_SUFFIXES = {'.json', '.jsonl'}
|
||||
FEISHU_DOC_MARKDOWN_MAX_CHARS = 100_000
|
||||
FEISHU_ONLINE_DOCS_FILENAME = 'online-docs.json'
|
||||
|
||||
|
||||
@dataclass
|
||||
class FeishuLoginProcess:
|
||||
process: subprocess.Popen[str]
|
||||
started_at: float
|
||||
output: list[str] = field(default_factory=list)
|
||||
login_url: str | None = None
|
||||
user_code: str | None = None
|
||||
|
||||
|
||||
_FEISHU_LOGIN_PROCESSES: dict[str, FeishuLoginProcess] = {}
|
||||
_FEISHU_LOGIN_LOCK = threading.Lock()
|
||||
_FEISHU_DOC_MAP_LOCK = threading.Lock()
|
||||
|
||||
VALIDATED_CHAT_MODEL_PROVIDERS = {
|
||||
# 这些 provider 已验证可以在当前 WebUI 中使用。
|
||||
@@ -892,6 +915,17 @@ class SessionTitleUpdate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=80)
|
||||
|
||||
|
||||
class FeishuAccountRequest(BaseModel):
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
class FeishuOnlineDocRequest(BaseModel):
|
||||
path: str = Field(min_length=1)
|
||||
title: str | None = None
|
||||
folder_token: str | None = None
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1000,6 +1034,90 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
payload.api_key or config.api_key,
|
||||
)
|
||||
|
||||
# ------------- Feishu integration ---------------------------------------
|
||||
@app.get('/api/integrations/feishu/status')
|
||||
async def feishu_status(account_id: str | None = None) -> dict[str, Any]:
|
||||
return _feishu_status_payload(state, account_id)
|
||||
|
||||
@app.post('/api/integrations/feishu/login')
|
||||
async def feishu_login(payload: FeishuAccountRequest) -> dict[str, Any]:
|
||||
return _start_feishu_login(state, payload.account_id)
|
||||
|
||||
@app.post('/api/integrations/feishu/logout')
|
||||
async def feishu_logout(payload: FeishuAccountRequest) -> dict[str, Any]:
|
||||
_stop_feishu_login(payload.account_id)
|
||||
paths = _feishu_paths(state, payload.account_id)
|
||||
result = _run_feishu_cli(paths, ['logout'], timeout_seconds=30)
|
||||
status = _feishu_status_payload(state, payload.account_id)
|
||||
status['logout_output'] = result['output']
|
||||
return status
|
||||
|
||||
@app.post('/api/files/online-doc')
|
||||
async def create_feishu_online_doc(payload: FeishuOnlineDocRequest) -> dict[str, Any]:
|
||||
file_path = _resolve_account_file(state, payload.account_id, payload.path)
|
||||
suffix = file_path.suffix.lower()
|
||||
if suffix in FEISHU_UNSUPPORTED_DOCUMENT_SUFFIXES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='当前先不支持 json/jsonl 转在线文档。',
|
||||
)
|
||||
if suffix not in FEISHU_SUPPORTED_DOCUMENT_SUFFIXES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='当前仅支持 md、txt、csv、docx、xlsx 转在线文档。',
|
||||
)
|
||||
status = _feishu_status_payload(state, payload.account_id)
|
||||
if not status.get('logged_in'):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
'code': 'feishu_not_logged_in',
|
||||
'message': '当前账号还没有完成飞书授权,请先授权后再转换。',
|
||||
'status': status,
|
||||
},
|
||||
)
|
||||
title = _clean_feishu_doc_title(payload.title or file_path.stem)
|
||||
try:
|
||||
markdown = _convert_file_to_feishu_markdown(file_path, title=title)
|
||||
except DataAgentInputError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail=f'读取文件失败: {exc}')
|
||||
|
||||
arguments: dict[str, Any] = {'title': title, 'markdown': markdown}
|
||||
if payload.folder_token and payload.folder_token.strip():
|
||||
arguments['folder_token'] = payload.folder_token.strip()
|
||||
paths = _feishu_paths(state, payload.account_id)
|
||||
runtime = MCPRuntime(servers=(_feishu_server_profile(paths),))
|
||||
try:
|
||||
rendered, metadata = runtime.call_tool(
|
||||
'doc_create',
|
||||
arguments=arguments,
|
||||
server_name=FEISHU_MCP_SERVER_NAME,
|
||||
max_chars=API_TOOL_CONTENT_MAX_CHARS,
|
||||
timeout_seconds=60.0,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=f'飞书文档创建失败: {exc}')
|
||||
url = _extract_first_url(rendered)
|
||||
if url:
|
||||
_record_feishu_online_doc(
|
||||
state,
|
||||
payload.account_id,
|
||||
file_path=file_path,
|
||||
title=title,
|
||||
url=url,
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'title': title,
|
||||
'url': url,
|
||||
'file_path': str(file_path),
|
||||
'file_type': suffix.lstrip('.'),
|
||||
'result': rendered,
|
||||
'metadata': metadata,
|
||||
}
|
||||
|
||||
# ------------- sessions --------------------------------------------------
|
||||
@app.get('/api/sessions')
|
||||
async def list_sessions(account_id: str | None = None) -> list[dict[str, Any]]:
|
||||
@@ -2150,6 +2268,424 @@ def _session_json_path(directory: Path, session_id: str) -> Path:
|
||||
return directory / f'{session_id}.json'
|
||||
|
||||
|
||||
def _feishu_paths(state: AgentState, account_id: str | None) -> dict[str, Path]:
|
||||
base = state.account_paths(account_id)['base'] / 'integrations' / 'feishu'
|
||||
paths = {
|
||||
'root': base,
|
||||
'home': base / 'home',
|
||||
'config': base / 'config',
|
||||
'npm_cache': base / 'npm-cache',
|
||||
}
|
||||
for path in paths.values():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return paths
|
||||
|
||||
|
||||
def _feishu_command() -> list[str]:
|
||||
return [
|
||||
'npx',
|
||||
'-y',
|
||||
f'--registry={FEISHU_NPM_REGISTRY}',
|
||||
FEISHU_MCP_PACKAGE,
|
||||
]
|
||||
|
||||
|
||||
def _feishu_env(paths: dict[str, Path]) -> dict[str, str]:
|
||||
return {
|
||||
'HOME': str(paths['home']),
|
||||
'XDG_CONFIG_HOME': str(paths['config']),
|
||||
'npm_config_cache': str(paths['npm_cache']),
|
||||
'npm_config_update_notifier': 'false',
|
||||
'NO_UPDATE_NOTIFIER': 'true',
|
||||
'FEISHU_LOGIN_MODE': 'devicecode',
|
||||
}
|
||||
|
||||
|
||||
def _run_feishu_cli(
|
||||
paths: dict[str, Path],
|
||||
args: list[str],
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
) -> dict[str, Any]:
|
||||
env = os.environ.copy()
|
||||
env.update(_feishu_env(paths))
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[*_feishu_command(), *args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
cwd=str(paths['root']),
|
||||
env=env,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
return {
|
||||
'returncode': completed.returncode,
|
||||
'output': completed.stdout.strip(),
|
||||
}
|
||||
except FileNotFoundError as exc:
|
||||
return {
|
||||
'returncode': 127,
|
||||
'output': f'缺少命令: {exc.filename}',
|
||||
}
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
output = exc.stdout or ''
|
||||
return {
|
||||
'returncode': 124,
|
||||
'output': str(output).strip() or '飞书状态检查超时',
|
||||
}
|
||||
|
||||
|
||||
def _feishu_status_payload(state: AgentState, account_id: str | None) -> dict[str, Any]:
|
||||
paths = _feishu_paths(state, account_id)
|
||||
_reap_feishu_login(account_id)
|
||||
result = _run_feishu_cli(paths, ['status'], timeout_seconds=30)
|
||||
output = str(result.get('output') or '')
|
||||
lowered = output.lower()
|
||||
logged_in = result.get('returncode') == 0 and not any(
|
||||
marker in lowered
|
||||
for marker in ('not logged in', '未登录', 'no credentials')
|
||||
)
|
||||
pending = _feishu_login_snapshot(account_id)
|
||||
payload: dict[str, Any] = {
|
||||
'logged_in': logged_in,
|
||||
'status': 'logged_in' if logged_in else 'not_logged_in',
|
||||
'output': output,
|
||||
}
|
||||
if pending:
|
||||
payload['login'] = pending
|
||||
if not logged_in:
|
||||
payload['status'] = 'login_pending'
|
||||
if not logged_in and result.get('returncode') not in (0, 1):
|
||||
payload['status'] = 'error'
|
||||
payload['error'] = output or '无法获取飞书登录状态'
|
||||
return payload
|
||||
|
||||
|
||||
def _start_feishu_login(state: AgentState, account_id: str | None) -> dict[str, Any]:
|
||||
status = _feishu_status_payload(state, account_id)
|
||||
if status.get('logged_in'):
|
||||
return status
|
||||
|
||||
key = _feishu_login_key(account_id)
|
||||
with _FEISHU_LOGIN_LOCK:
|
||||
existing = _FEISHU_LOGIN_PROCESSES.get(key)
|
||||
if existing is not None and existing.process.poll() is None:
|
||||
return {
|
||||
'logged_in': False,
|
||||
'status': 'login_pending',
|
||||
'login': _feishu_login_snapshot_unlocked(existing),
|
||||
}
|
||||
_FEISHU_LOGIN_PROCESSES.pop(key, None)
|
||||
|
||||
paths = _feishu_paths(state, account_id)
|
||||
env = os.environ.copy()
|
||||
env.update(_feishu_env(paths))
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[*_feishu_command(), 'login', '--device'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
cwd=str(paths['root']),
|
||||
env=env,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=500, detail=f'缺少命令: {exc.filename}')
|
||||
|
||||
entry = FeishuLoginProcess(process=process, started_at=time.time())
|
||||
with _FEISHU_LOGIN_LOCK:
|
||||
_FEISHU_LOGIN_PROCESSES[key] = entry
|
||||
|
||||
reader = threading.Thread(
|
||||
target=_read_feishu_login_output,
|
||||
args=(key, entry),
|
||||
name=f'feishu-login-{key}',
|
||||
daemon=True,
|
||||
)
|
||||
reader.start()
|
||||
|
||||
deadline = time.time() + 5.0
|
||||
while time.time() < deadline:
|
||||
snapshot = _feishu_login_snapshot(account_id)
|
||||
if snapshot and snapshot.get('login_url'):
|
||||
return {
|
||||
'logged_in': False,
|
||||
'status': 'login_pending',
|
||||
'login': snapshot,
|
||||
}
|
||||
if process.poll() is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
snapshot = _feishu_login_snapshot(account_id)
|
||||
return {
|
||||
'logged_in': False,
|
||||
'status': 'login_pending' if process.poll() is None else 'login_failed',
|
||||
'login': snapshot,
|
||||
}
|
||||
|
||||
|
||||
def _stop_feishu_login(account_id: str | None) -> None:
|
||||
key = _feishu_login_key(account_id)
|
||||
with _FEISHU_LOGIN_LOCK:
|
||||
entry = _FEISHU_LOGIN_PROCESSES.pop(key, None)
|
||||
if entry is None:
|
||||
return
|
||||
process = entry.process
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=2.0)
|
||||
|
||||
|
||||
def _reap_feishu_login(account_id: str | None) -> None:
|
||||
key = _feishu_login_key(account_id)
|
||||
with _FEISHU_LOGIN_LOCK:
|
||||
entry = _FEISHU_LOGIN_PROCESSES.get(key)
|
||||
if entry is not None and entry.process.poll() is not None:
|
||||
_FEISHU_LOGIN_PROCESSES.pop(key, None)
|
||||
|
||||
|
||||
def _read_feishu_login_output(key: str, entry: FeishuLoginProcess) -> None:
|
||||
stream = entry.process.stdout
|
||||
if stream is None:
|
||||
return
|
||||
for line in stream:
|
||||
with _FEISHU_LOGIN_LOCK:
|
||||
current = _FEISHU_LOGIN_PROCESSES.get(key)
|
||||
if current is not entry:
|
||||
return
|
||||
entry.output.append(line.rstrip())
|
||||
text = '\n'.join(entry.output)
|
||||
login_url, user_code = _parse_feishu_login_details(text)
|
||||
if login_url:
|
||||
entry.login_url = login_url
|
||||
if user_code:
|
||||
entry.user_code = user_code
|
||||
|
||||
|
||||
def _feishu_login_snapshot(account_id: str | None) -> dict[str, Any] | None:
|
||||
key = _feishu_login_key(account_id)
|
||||
with _FEISHU_LOGIN_LOCK:
|
||||
entry = _FEISHU_LOGIN_PROCESSES.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
return _feishu_login_snapshot_unlocked(entry)
|
||||
|
||||
|
||||
def _feishu_login_snapshot_unlocked(entry: FeishuLoginProcess) -> dict[str, Any]:
|
||||
output = '\n'.join(entry.output[-20:])
|
||||
if not entry.login_url or not entry.user_code:
|
||||
login_url, user_code = _parse_feishu_login_details(output)
|
||||
entry.login_url = entry.login_url or login_url
|
||||
entry.user_code = entry.user_code or user_code
|
||||
return {
|
||||
'running': entry.process.poll() is None,
|
||||
'started_at': entry.started_at,
|
||||
'elapsed_seconds': max(0, int(time.time() - entry.started_at)),
|
||||
'login_url': entry.login_url,
|
||||
'user_code': entry.user_code,
|
||||
'output': output,
|
||||
}
|
||||
|
||||
|
||||
def _parse_feishu_login_details(text: str) -> tuple[str | None, str | None]:
|
||||
urls = re.findall(r'https?://[^\s]+', text)
|
||||
login_url = next((url.rstrip('.,;') for url in urls if 'feishu' in url.lower()), None)
|
||||
user_code = None
|
||||
match = re.search(r'user_code=([A-Za-z0-9_-]+)', login_url or '')
|
||||
if match:
|
||||
user_code = match.group(1)
|
||||
if not user_code:
|
||||
match = re.search(r'\b([A-Z0-9]{4}-[A-Z0-9]{4})\b', text)
|
||||
if match:
|
||||
user_code = match.group(1)
|
||||
return login_url, user_code
|
||||
|
||||
|
||||
def _feishu_login_key(account_id: str | None) -> str:
|
||||
return _safe_account_id(account_id)
|
||||
|
||||
|
||||
def _feishu_server_profile(paths: dict[str, Path]) -> MCPServerProfile:
|
||||
return MCPServerProfile(
|
||||
name=FEISHU_MCP_SERVER_NAME,
|
||||
source_manifest='builtin:feishu-mcp-pro',
|
||||
transport='stdio',
|
||||
command='npx',
|
||||
args=(
|
||||
'-y',
|
||||
f'--registry={FEISHU_NPM_REGISTRY}',
|
||||
FEISHU_MCP_PACKAGE,
|
||||
),
|
||||
env=_feishu_env(paths),
|
||||
cwd=str(paths['root']),
|
||||
description='账号隔离的飞书 MCP Pro。',
|
||||
)
|
||||
|
||||
|
||||
def _record_feishu_online_doc(
|
||||
state: AgentState,
|
||||
account_id: str | None,
|
||||
*,
|
||||
file_path: Path,
|
||||
title: str,
|
||||
url: str,
|
||||
) -> None:
|
||||
paths = _feishu_paths(state, account_id)
|
||||
map_path = paths['root'] / FEISHU_ONLINE_DOCS_FILENAME
|
||||
now = int(time.time())
|
||||
clean_url = _clean_url(url)
|
||||
with _FEISHU_DOC_MAP_LOCK:
|
||||
try:
|
||||
payload = json.loads(map_path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
payload = {}
|
||||
files = payload.get('files') if isinstance(payload, dict) else None
|
||||
if not isinstance(files, dict):
|
||||
files = {}
|
||||
previous = files.get(str(file_path))
|
||||
created_at = (
|
||||
previous.get('created_at')
|
||||
if isinstance(previous, dict) and isinstance(previous.get('created_at'), int)
|
||||
else now
|
||||
)
|
||||
files[str(file_path)] = {
|
||||
'url': clean_url,
|
||||
'title': title,
|
||||
'file_path': str(file_path),
|
||||
'created_at': created_at,
|
||||
'updated_at': now,
|
||||
}
|
||||
map_path.write_text(
|
||||
json.dumps({'files': files}, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
|
||||
def _resolve_account_file(state: AgentState, account_id: str | None, raw_path: str) -> Path:
|
||||
path = Path(raw_path).expanduser().resolve()
|
||||
account_base = state.account_paths(account_id)['base'].resolve()
|
||||
try:
|
||||
path.relative_to(account_base)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=403, detail='文件不在当前账号目录内')
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail='文件不存在')
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=400, detail='目标不是文件')
|
||||
return path
|
||||
|
||||
|
||||
def _clean_feishu_doc_title(value: str) -> str:
|
||||
title = re.sub(r'[\r\n\t/\\]+', ' ', value).strip()
|
||||
return title[:80].strip() or 'Claw 生成文档'
|
||||
|
||||
|
||||
def _convert_file_to_feishu_markdown(file_path: Path, *, title: str) -> str:
|
||||
suffix = file_path.suffix.lower()
|
||||
if suffix == '.md':
|
||||
body = file_path.read_text(encoding='utf-8', errors='replace')
|
||||
return _limit_feishu_markdown(f'# {title}\n\n> 来源文件:{file_path.name}\n\n{body}')
|
||||
if suffix == '.txt':
|
||||
body = file_path.read_text(encoding='utf-8', errors='replace')
|
||||
return _limit_feishu_markdown(f'# {title}\n\n> 来源文件:{file_path.name}\n\n{body}')
|
||||
|
||||
loaded = load_input_sources(
|
||||
file_path.parent,
|
||||
[file_path.name],
|
||||
max_files=1,
|
||||
max_paragraphs_per_file=200,
|
||||
max_tables_per_file=30,
|
||||
max_rows_per_table=120,
|
||||
max_cell_chars=500,
|
||||
)
|
||||
sources = loaded.get('sources')
|
||||
if not isinstance(sources, list) or not sources:
|
||||
raise DataAgentInputError('没有解析到可转成在线文档的内容')
|
||||
source = sources[0]
|
||||
lines = [
|
||||
f'# {title}',
|
||||
'',
|
||||
f'> 来源文件:{file_path.name}',
|
||||
f'> 文件类型:{suffix.lstrip(".")}',
|
||||
]
|
||||
warnings = source.get('warnings')
|
||||
if isinstance(warnings, list) and warnings:
|
||||
lines.append(f'> 解析提示:{";".join(str(item) for item in warnings)}')
|
||||
for paragraph in source.get('paragraphs', []):
|
||||
if not isinstance(paragraph, dict):
|
||||
continue
|
||||
text = str(paragraph.get('text') or '').strip()
|
||||
if text:
|
||||
lines.extend(['', text])
|
||||
for table in source.get('tables', []):
|
||||
if not isinstance(table, dict):
|
||||
continue
|
||||
rows = table.get('rows')
|
||||
if not isinstance(rows, list) or not rows:
|
||||
continue
|
||||
title_text = str(table.get('title') or '表格').strip() or '表格'
|
||||
lines.extend(['', f'## {title_text}', ''])
|
||||
lines.append(_render_markdown_table(rows))
|
||||
row_count = table.get('row_count')
|
||||
if isinstance(row_count, int) and row_count > len(rows):
|
||||
lines.append(f'\n> 仅展示前 {len(rows)} 行,原表约 {row_count} 行。')
|
||||
markdown = '\n'.join(lines).strip()
|
||||
if not markdown:
|
||||
raise DataAgentInputError('没有解析到可转成在线文档的内容')
|
||||
return _limit_feishu_markdown(markdown)
|
||||
|
||||
|
||||
def _render_markdown_table(raw_rows: list[Any]) -> str:
|
||||
rows = [
|
||||
[str(cell) for cell in row]
|
||||
for row in raw_rows
|
||||
if isinstance(row, list)
|
||||
]
|
||||
if not rows:
|
||||
return ''
|
||||
width = max(len(row) for row in rows)
|
||||
normalized = [row + [''] * (width - len(row)) for row in rows]
|
||||
header = normalized[0] if any(cell.strip() for cell in normalized[0]) else [
|
||||
f'列{index + 1}' for index in range(width)
|
||||
]
|
||||
body = normalized[1:] if header is normalized[0] else normalized
|
||||
lines = [
|
||||
'| ' + ' | '.join(_escape_markdown_table_cell(cell) for cell in header) + ' |',
|
||||
'| ' + ' | '.join('---' for _ in range(width)) + ' |',
|
||||
]
|
||||
for row in body:
|
||||
lines.append('| ' + ' | '.join(_escape_markdown_table_cell(cell) for cell in row) + ' |')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _escape_markdown_table_cell(value: str) -> str:
|
||||
return value.replace('\\', '\\\\').replace('|', '\\|').replace('\n', ' ').strip()
|
||||
|
||||
|
||||
def _limit_feishu_markdown(markdown: str) -> str:
|
||||
if len(markdown) <= FEISHU_DOC_MARKDOWN_MAX_CHARS:
|
||||
return markdown
|
||||
suffix = '\n\n> 内容较长,已截断后写入在线文档。'
|
||||
return markdown[: FEISHU_DOC_MARKDOWN_MAX_CHARS - len(suffix)].rstrip() + suffix
|
||||
|
||||
|
||||
def _extract_first_url(text: str) -> str | None:
|
||||
match = re.search(r'https?://[^\s<>\]\)\"\'“”‘’]+', text)
|
||||
return _clean_url(match.group(0)) if match else None
|
||||
|
||||
|
||||
def _clean_url(value: str) -> str:
|
||||
return value.strip().rstrip('.,;,。;、"\'“”‘’')
|
||||
|
||||
|
||||
def _safe_account_id(account_id: str | None) -> str:
|
||||
normalized = re.sub(r'[^a-zA-Z0-9._-]+', '_', (account_id or '').strip())
|
||||
return normalized[:80] or 'default'
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||
|
||||
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
path?: unknown;
|
||||
title?: unknown;
|
||||
folder_token?: unknown;
|
||||
};
|
||||
const filePath = typeof body.path === "string" ? body.path.trim() : "";
|
||||
if (!filePath) {
|
||||
return Response.json({ error: "Missing file path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${CLAW_API_URL}/api/files/online-doc`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
account_id: account.id,
|
||||
path: filePath,
|
||||
...(typeof body.title === "string" && body.title.trim()
|
||||
? { title: body.title.trim() }
|
||||
: {}),
|
||||
...(typeof body.folder_token === "string" && body.folder_token.trim()
|
||||
? { folder_token: body.folder_token.trim() }
|
||||
: {}),
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const payload = await response.text();
|
||||
return new Response(payload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -17,11 +17,17 @@ export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const requestedPath = url.searchParams.get("path");
|
||||
const sessionId = normalizeSessionId(url.searchParams.get("session_id"));
|
||||
const onlineDocs = await readOnlineDocMap(account.id);
|
||||
if (!requestedPath && sessionId) {
|
||||
return Response.json({
|
||||
session_id: sessionId,
|
||||
input: await listSessionFiles(account.id, sessionId, "input"),
|
||||
output: await listSessionFiles(account.id, sessionId, "output"),
|
||||
input: await listSessionFiles(account.id, sessionId, "input", onlineDocs),
|
||||
output: await listSessionFiles(
|
||||
account.id,
|
||||
sessionId,
|
||||
"output",
|
||||
onlineDocs,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (!requestedPath) {
|
||||
@@ -31,8 +37,18 @@ export async function GET(req: Request) {
|
||||
}
|
||||
return Response.json({
|
||||
session_id: latestSessionId,
|
||||
input: await listSessionFiles(account.id, latestSessionId, "input"),
|
||||
output: await listSessionFiles(account.id, latestSessionId, "output"),
|
||||
input: await listSessionFiles(
|
||||
account.id,
|
||||
latestSessionId,
|
||||
"input",
|
||||
onlineDocs,
|
||||
),
|
||||
output: await listSessionFiles(
|
||||
account.id,
|
||||
latestSessionId,
|
||||
"output",
|
||||
onlineDocs,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,6 +79,7 @@ async function listSessionFiles(
|
||||
accountId: string,
|
||||
sessionId: string,
|
||||
kind: "input" | "output",
|
||||
onlineDocs: Record<string, OnlineDocRecord>,
|
||||
) {
|
||||
const accountRoot = path.resolve(accountBaseRoot(accountId));
|
||||
const dir =
|
||||
@@ -80,6 +97,7 @@ async function listSessionFiles(
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(resolvedDir, entry.name);
|
||||
const fileStat = await stat(filePath);
|
||||
const onlineDoc = onlineDocs[path.resolve(filePath)];
|
||||
return {
|
||||
name: entry.name,
|
||||
path: filePath,
|
||||
@@ -87,6 +105,9 @@ async function listSessionFiles(
|
||||
size: fileStat.size,
|
||||
modified_at: fileStat.mtime.toISOString(),
|
||||
download_url: `/api/claw/files?path=${encodeURIComponent(filePath)}`,
|
||||
online_doc_url: cleanOnlineDocUrl(onlineDoc?.url) ?? null,
|
||||
online_doc_title: onlineDoc?.title ?? null,
|
||||
online_doc_updated_at: onlineDoc?.updated_at ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -96,6 +117,34 @@ async function listSessionFiles(
|
||||
}
|
||||
}
|
||||
|
||||
function cleanOnlineDocUrl(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
return value.trim().replace(/[.,;,。;、"'“”‘’]+$/u, "") || null;
|
||||
}
|
||||
|
||||
type OnlineDocRecord = {
|
||||
url?: string;
|
||||
title?: string;
|
||||
updated_at?: number;
|
||||
};
|
||||
|
||||
async function readOnlineDocMap(accountId: string) {
|
||||
const mapPath = path.join(
|
||||
accountBaseRoot(accountId),
|
||||
"integrations",
|
||||
"feishu",
|
||||
"online-docs.json",
|
||||
);
|
||||
try {
|
||||
const payload = JSON.parse(await readFile(mapPath, "utf8")) as {
|
||||
files?: Record<string, OnlineDocRecord>;
|
||||
};
|
||||
return payload.files ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSessionId(value: string | null) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed || null;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||
|
||||
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||
|
||||
export async function GET() {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const url = new URL(`${CLAW_API_URL}/api/integrations/feishu/status`);
|
||||
url.searchParams.set("account_id", account.id);
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
return proxyResponse(response);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
action?: unknown;
|
||||
};
|
||||
const action = typeof body.action === "string" ? body.action : "login";
|
||||
const endpoint =
|
||||
action === "logout"
|
||||
? "/api/integrations/feishu/logout"
|
||||
: "/api/integrations/feishu/login";
|
||||
|
||||
const response = await fetch(`${CLAW_API_URL}${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ account_id: account.id }),
|
||||
cache: "no-store",
|
||||
});
|
||||
return proxyResponse(response);
|
||||
}
|
||||
|
||||
async function proxyResponse(response: Response) {
|
||||
const payload = await response.text();
|
||||
return new Response(payload, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ClockIcon,
|
||||
CloudUploadIcon,
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
Loader2Icon,
|
||||
MessageSquareTextIcon,
|
||||
PanelRightCloseIcon,
|
||||
WrenchIcon,
|
||||
@@ -219,6 +221,9 @@ type SessionFile = {
|
||||
size: number;
|
||||
modified_at: string;
|
||||
download_url: string;
|
||||
online_doc_url?: string | null;
|
||||
online_doc_title?: string | null;
|
||||
online_doc_updated_at?: number | null;
|
||||
};
|
||||
|
||||
type SessionFilesPayload = {
|
||||
@@ -383,19 +388,157 @@ function FileSection({
|
||||
}
|
||||
|
||||
function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
const [onlineDoc, setOnlineDoc] = useState<{
|
||||
status: "idle" | "loading" | "auth" | "done" | "error";
|
||||
message?: string;
|
||||
url?: string | null;
|
||||
}>({
|
||||
status: file.online_doc_url ? "done" : "idle",
|
||||
message: file.online_doc_url ? "已转在线文档" : undefined,
|
||||
url: file.online_doc_url ?? null,
|
||||
});
|
||||
const canCreateOnlineDoc = isOnlineDocSupported(file.name);
|
||||
const onlineDocUrl = onlineDoc.url ?? file.online_doc_url ?? null;
|
||||
|
||||
async function createOnlineDoc() {
|
||||
if (!canCreateOnlineDoc || onlineDoc.status === "loading") return;
|
||||
let pendingWindow: Window | null = null;
|
||||
try {
|
||||
pendingWindow = window.open("about:blank", "_blank");
|
||||
if (pendingWindow) {
|
||||
pendingWindow.document.write("正在创建飞书在线文档...");
|
||||
}
|
||||
} catch {
|
||||
pendingWindow = null;
|
||||
}
|
||||
setOnlineDoc({ status: "loading", message: "正在转换..." });
|
||||
try {
|
||||
const response = await fetch("/api/claw/files/online-doc", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ path: file.path, title: file.name }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (response.status === 409) {
|
||||
const loginResponse = await fetch("/api/claw/integrations/feishu", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ action: "login" }),
|
||||
});
|
||||
const loginPayload = (await loginResponse
|
||||
.json()
|
||||
.catch(() => ({}))) as FeishuLoginPayload;
|
||||
if (!loginResponse.ok) {
|
||||
pendingWindow?.close();
|
||||
setOnlineDoc({
|
||||
status: "error",
|
||||
message:
|
||||
extractErrorMessage(
|
||||
loginPayload as unknown as Record<string, unknown>,
|
||||
) ?? "飞书授权启动失败",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const loginUrl = loginPayload.login?.login_url ?? null;
|
||||
if (loginUrl) {
|
||||
if (pendingWindow) {
|
||||
try {
|
||||
pendingWindow.opener = null;
|
||||
} catch {
|
||||
// 有些浏览器不允许改 opener,不影响后续跳转。
|
||||
}
|
||||
pendingWindow.location.href = loginUrl;
|
||||
} else {
|
||||
window.open(loginUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} else if (pendingWindow) {
|
||||
pendingWindow.close();
|
||||
}
|
||||
setOnlineDoc({
|
||||
status: "auth",
|
||||
message: loginUrl ? "完成授权后再点一次" : "需要先完成飞书授权",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
pendingWindow?.close();
|
||||
setOnlineDoc({
|
||||
status: "error",
|
||||
message: extractErrorMessage(payload) ?? "转换失败",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const url = typeof payload.url === "string" ? payload.url : null;
|
||||
if (url) {
|
||||
if (pendingWindow) {
|
||||
try {
|
||||
pendingWindow.opener = null;
|
||||
} catch {
|
||||
// 有些浏览器不允许改 opener,不影响后续跳转。
|
||||
}
|
||||
pendingWindow.location.href = url;
|
||||
} else {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} else {
|
||||
pendingWindow?.close();
|
||||
}
|
||||
setOnlineDoc({
|
||||
status: "done",
|
||||
message: url ? "已生成在线文档" : "已生成",
|
||||
url,
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent("claw-session-files-changed"));
|
||||
} catch (error) {
|
||||
pendingWindow?.close();
|
||||
setOnlineDoc({
|
||||
status: "error",
|
||||
message: error instanceof Error ? error.message : "转换失败",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border px-3 py-2">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<FileTextIcon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-sm" title={file.name}>
|
||||
{file.name}
|
||||
</div>
|
||||
{onlineDocUrl ? (
|
||||
<a
|
||||
href={onlineDocUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block truncate font-medium text-primary text-sm underline-offset-4 hover:underline"
|
||||
title={`${file.name} · 打开在线文档`}
|
||||
>
|
||||
{file.name}
|
||||
</a>
|
||||
) : (
|
||||
<div className="truncate font-medium text-sm" title={file.name}>
|
||||
{file.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{fileExtension(file.name).toUpperCase() || "FILE"} ·{" "}
|
||||
{formatBytes(file.size)}
|
||||
</div>
|
||||
{onlineDoc.message ? (
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-xs",
|
||||
onlineDoc.status === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
title={onlineDoc.message}
|
||||
>
|
||||
{onlineDoc.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<a
|
||||
href={file.download_url}
|
||||
@@ -414,10 +557,50 @@ function SessionFileRow({ file }: { file: SessionFile }) {
|
||||
>
|
||||
<MessageSquareTextIcon className="size-4" />
|
||||
</button>
|
||||
{canCreateOnlineDoc ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`将 ${file.name} 转为在线文档`}
|
||||
title="转为在线文档"
|
||||
disabled={onlineDoc.status === "loading"}
|
||||
onClick={createOnlineDoc}
|
||||
>
|
||||
{onlineDoc.status === "loading" ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CloudUploadIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FeishuLoginPayload = {
|
||||
login?: {
|
||||
login_url?: string | null;
|
||||
user_code?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function isOnlineDocSupported(fileName: string) {
|
||||
return ["csv", "docx", "md", "txt", "xlsx"].includes(
|
||||
fileExtension(fileName).toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function extractErrorMessage(payload: Record<string, unknown>) {
|
||||
const detail = payload.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (detail && typeof detail === "object") {
|
||||
const message = (detail as { message?: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
const error = payload.error;
|
||||
return typeof error === "string" ? error : null;
|
||||
}
|
||||
|
||||
function askAboutFile(file: SessionFile) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("claw-composer-insert", {
|
||||
|
||||
+4
-1
@@ -170,6 +170,7 @@ class MCPRuntime:
|
||||
arguments: dict[str, Any] | None = None,
|
||||
server_name: str | None = None,
|
||||
max_chars: int = 12000,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
tool = self._resolve_tool(tool_name, server_name=server_name)
|
||||
server = self.get_server(tool.server_name)
|
||||
@@ -179,7 +180,7 @@ class MCPRuntime:
|
||||
'name': tool.name,
|
||||
'arguments': dict(arguments or {}),
|
||||
}
|
||||
result = _request_stdio(server, 'tools/call', payload)
|
||||
result = _request_stdio(server, 'tools/call', payload, timeout_seconds=timeout_seconds)
|
||||
rendered = _truncate(_render_tool_call_result(result), max_chars)
|
||||
metadata = {
|
||||
'server_name': tool.server_name,
|
||||
@@ -292,12 +293,14 @@ class MCPRuntime:
|
||||
arguments: dict[str, Any] | None = None,
|
||||
server_name: str | None = None,
|
||||
max_chars: int = 12000,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> str:
|
||||
content, metadata = self.call_tool(
|
||||
tool_name,
|
||||
arguments=arguments,
|
||||
server_name=server_name,
|
||||
max_chars=max_chars,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
lines = [
|
||||
'# MCP Tool Result',
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import openpyxl
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.api import server as gui_server
|
||||
from backend.api.server import AgentState, create_app
|
||||
|
||||
|
||||
def _build_client(tmp: Path) -> tuple[TestClient, AgentState]:
|
||||
state = AgentState(
|
||||
cwd=tmp,
|
||||
model='test-model',
|
||||
base_url='http://127.0.0.1:8000/v1',
|
||||
api_key='local-token',
|
||||
allow_shell=False,
|
||||
allow_write=False,
|
||||
session_directory=tmp / 'sessions',
|
||||
)
|
||||
return TestClient(create_app(state)), state
|
||||
|
||||
|
||||
class FeishuIntegrationTests(unittest.TestCase):
|
||||
def test_convert_xlsx_and_docx_to_markdown(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
root = Path(tmp_dir)
|
||||
xlsx_path = root / '样例.xlsx'
|
||||
docx_path = root / '说明.docx'
|
||||
_write_xlsx(xlsx_path)
|
||||
_write_docx(docx_path)
|
||||
|
||||
xlsx_markdown = gui_server._convert_file_to_feishu_markdown(
|
||||
xlsx_path,
|
||||
title='表格样例',
|
||||
)
|
||||
docx_markdown = gui_server._convert_file_to_feishu_markdown(
|
||||
docx_path,
|
||||
title='文档样例',
|
||||
)
|
||||
|
||||
self.assertIn('| query | label |', xlsx_markdown)
|
||||
self.assertIn('| 怎么去公司 | Agent(tag="地图导航") |', xlsx_markdown)
|
||||
self.assertIn('产品定义说明', docx_markdown)
|
||||
self.assertIn('| 功能点 | 示例 |', docx_markdown)
|
||||
|
||||
def test_create_online_doc_requires_feishu_login(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
client, state = _build_client(Path(tmp_dir))
|
||||
file_path = _write_account_file(state, 'alice', 'draft.md', '# hello')
|
||||
|
||||
with patch.object(
|
||||
gui_server,
|
||||
'_feishu_status_payload',
|
||||
return_value={'logged_in': False, 'status': 'not_logged_in'},
|
||||
):
|
||||
response = client.post(
|
||||
'/api/files/online-doc',
|
||||
json={'account_id': 'alice', 'path': str(file_path)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()['detail']['code'], 'feishu_not_logged_in')
|
||||
|
||||
def test_create_online_doc_calls_feishu_mcp(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
client, state = _build_client(Path(tmp_dir))
|
||||
file_path = _write_account_file(state, 'alice', 'draft.md', '# hello')
|
||||
|
||||
with patch.object(
|
||||
gui_server,
|
||||
'_feishu_status_payload',
|
||||
return_value={'logged_in': True, 'status': 'logged_in'},
|
||||
), patch.object(
|
||||
gui_server.MCPRuntime,
|
||||
'call_tool',
|
||||
return_value=(
|
||||
'{"url":"https://mi.feishu.cn/docx/example"}',
|
||||
{'server_name': 'feishu-mcp-pro', 'tool_name': 'doc_create'},
|
||||
),
|
||||
) as call_tool:
|
||||
response = client.post(
|
||||
'/api/files/online-doc',
|
||||
json={'account_id': 'alice', 'path': str(file_path)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload['url'], 'https://mi.feishu.cn/docx/example')
|
||||
call_tool.assert_called_once()
|
||||
self.assertEqual(call_tool.call_args.args[0], 'doc_create')
|
||||
map_path = (
|
||||
state.account_paths('alice')['base']
|
||||
/ 'integrations'
|
||||
/ 'feishu'
|
||||
/ 'online-docs.json'
|
||||
)
|
||||
online_docs = json.loads(map_path.read_text(encoding='utf-8'))
|
||||
self.assertEqual(
|
||||
online_docs['files'][str(file_path)]['url'],
|
||||
'https://mi.feishu.cn/docx/example',
|
||||
)
|
||||
|
||||
def test_extract_first_url_ignores_wrapping_quotes(self) -> None:
|
||||
url = gui_server._extract_first_url(
|
||||
'{"url":"https://mi.feishu.cn/docx/CZIldpM3QofbbXxxAq1cEininTd"}'
|
||||
)
|
||||
|
||||
self.assertEqual(url, 'https://mi.feishu.cn/docx/CZIldpM3QofbbXxxAq1cEininTd')
|
||||
|
||||
def test_create_online_doc_rejects_jsonl_for_now(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
client, state = _build_client(Path(tmp_dir))
|
||||
file_path = _write_account_file(state, 'alice', 'records.jsonl', '{}\n')
|
||||
|
||||
response = client.post(
|
||||
'/api/files/online-doc',
|
||||
json={'account_id': 'alice', 'path': str(file_path)},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('json/jsonl', response.json()['detail'])
|
||||
|
||||
|
||||
def _write_account_file(
|
||||
state: AgentState,
|
||||
account_id: str,
|
||||
name: str,
|
||||
content: str,
|
||||
) -> Path:
|
||||
path = state.account_paths(account_id)['sessions'] / 's1' / 'output' / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding='utf-8')
|
||||
return path
|
||||
|
||||
|
||||
def _write_xlsx(path: Path) -> None:
|
||||
workbook = openpyxl.Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = 'Sheet1'
|
||||
sheet.append(['query', 'label'])
|
||||
sheet.append(['怎么去公司', 'Agent(tag="地图导航")'])
|
||||
workbook.save(path)
|
||||
|
||||
|
||||
def _write_docx(path: Path) -> None:
|
||||
document_xml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:body>
|
||||
<w:p><w:r><w:t>产品定义说明</w:t></w:r></w:p>
|
||||
<w:tbl>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>功能点</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>示例</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>导航</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>导航到公司</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
</w:tbl>
|
||||
</w:body>
|
||||
</w:document>
|
||||
'''
|
||||
with zipfile.ZipFile(path, 'w') as archive:
|
||||
archive.writestr('word/document.xml', document_xml)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user