Add Feishu online document conversion

This commit is contained in:
wuyang6
2026-05-11 17:18:40 +08:00
parent 64ad7aa97a
commit 2984cc44e8
7 changed files with 1045 additions and 8 deletions
+536
View File
@@ -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",
},
});
}
+53 -4
View File
@@ -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">
{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
View File
@@ -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',
+175
View File
@@ -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()