Zoey7Web's picture
Upload 2 files
7a765e0 verified
Raw
History Blame Contribute Delete
30 kB
"""
邮件解析 & AI 分类查看器 - 增强版
- 上传/拖拽 .msg 文件(支持多文件)
- AI 自动提取:公司名、邮件类型、正文内容
- 新增:邮件正文翻译(并排对照展示,无复制按钮)
- 新增:AI 深度解析(智能分析邮件内容)
- 公司名/类型/正文支持一键复制
- 部署:Hugging Face Spaces (Gradio SDK)
"""
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
from typing import Optional
import gradio as gr
# 兼容补丁:Gradio 4.44.0 的 gradio_client/utils.py 在 JSON schema 中
# additionalProperties=True(bool)时会崩溃:`if "const" in schema` 不适用于 bool。
# Hugging Face Spaces 强制安装 gradio[oauth]==4.44.0,无法升级,故运行时打补丁。
try:
import gradio_client.utils as _client_utils
# 补丁 1:get_type 遇到 bool schema 时返回 Any,避免
# `if "const" in schema` 对 bool 执行 `in` 操作而崩溃。
_orig_get_type = _client_utils.get_type
def _patched_get_type(schema):
if isinstance(schema, bool):
return "Any"
return _orig_get_type(schema)
_client_utils.get_type = _patched_get_type
# 补丁 2:_json_schema_to_python_type 递归遇到 bool schema 时直接返回 "Any",
# 避免走到末尾抛出 APIInfoParseError: Cannot parse schema True
_orig_json_schema_to_python_type = _client_utils._json_schema_to_python_type
def _patched_json_schema_to_python_type(schema, defs):
if isinstance(schema, bool):
return "Any"
return _orig_json_schema_to_python_type(schema, defs)
_client_utils._json_schema_to_python_type = _patched_json_schema_to_python_type
except Exception:
pass
# 补丁 3:禁用 Jinja2 模板缓存,避免 cache key 类型错误
# Jinja2 3.1.5+ 修改了缓存 key 的生成方式,与 Gradio 4.44.0 的 Starlette
# 模板渲染不兼容,导致 TypeError: unhashable type: 'dict'。
# 禁用缓存(cache=None)可彻底规避此问题,对性能影响极小。
try:
from starlette.templating import Jinja2Templates as _J2T
_orig_j2t_init = _J2T.__init__
def _patched_j2t_init(self, *args, **kwargs):
_orig_j2t_init(self, *args, **kwargs)
self.env.cache = None # 禁用模板缓存
_J2T.__init__ = _patched_j2t_init
except Exception:
pass
sys.path.insert(0, str(Path(__file__).parent))
from src import config as cfg
from src.email_parser import parse_msg_file, ParsedEmail
from src.ai_classifier import classify_email_sync, ClassificationResult
from src.translator import translate_email_sync, TranslationResult
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
# ────────────────────────────────────────────
# CSS & JS:事件委托复制(无内联 onclick)
# ────────────────────────────────────────────
CUSTOM_CSS = """
.gradio-container { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; }
.header-banner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px; padding: 28px 32px; margin-bottom: 8px; color: white;
}
.header-banner h1 { margin: 0 0 6px 0; font-size: 1.7rem; font-weight: 700; }
.header-banner p { margin: 0; opacity: 0.85; font-size: 0.95rem; }
.config-panel { border: 1px solid #e2e8f0 !important; border-radius: 12px !important; }
.email-card {
background: #fff; border: 1px solid #e2e8f0; border-radius: 14px;
padding: 20px 24px; margin-bottom: 14px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06); transition: box-shadow 0.2s;
}
.email-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.10); }
.email-card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.email-filename { font-size: 0.78rem; color: #94a3b8; margin-left: auto; font-family: monospace; }
.category-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 12px; border-radius: 20px; font-size: 0.82rem; font-weight: 600;
color: white; white-space: nowrap;
}
.field-row {
display: flex; align-items: flex-start; gap: 10px;
margin-bottom: 10px; padding: 10px 14px;
background: #f8fafc; border-radius: 8px; border: 1px solid #f1f5f9;
}
.field-label {
font-size: 0.72rem; font-weight: 700; color: #64748b;
text-transform: uppercase; letter-spacing: 0.05em;
min-width: 64px; padding-top: 2px; white-space: nowrap;
}
.field-value {
flex: 1; font-size: 0.88rem; color: #1e293b;
line-height: 1.5; word-break: break-word;
max-height: 120px; overflow-y: auto;
}
.field-value.company { font-weight: 700; font-size: 1rem; color: #0f172a; }
.copy-btn {
background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 6px;
padding: 4px 10px; font-size: 0.75rem; color: #475569;
cursor: pointer; white-space: nowrap; transition: all 0.15s; flex-shrink: 0; margin-top: 1px;
}
.copy-btn:hover { background: #e2e8f0; color: #0f172a; }
.copy-btn.copied { background: #dcfce7; border-color: #86efac; color: #166534; }
.summary-text { font-size: 0.82rem; color: #64748b; font-style: italic; padding: 6px 14px; border-left: 3px solid #e2e8f0; margin-bottom: 4px; }
.meta-row { display: flex; gap: 16px; font-size: 0.78rem; color: #94a3b8; flex-wrap: wrap; margin-top: 6px; }
.meta-row span { display: flex; align-items: center; gap: 4px; }
/* 翻译对照区域 */
.translation-section {
margin-top: 14px; border: 1px solid #e2e8f0; border-radius: 10px;
overflow: hidden; background: #f8fafc;
}
.translation-header {
background: #f1f5f9; padding: 8px 14px; font-size: 0.78rem; font-weight: 600;
color: #475569; border-bottom: 1px solid #e2e8f0; display: flex; align-items: center; gap: 8px;
}
.translation-columns { display: flex; min-height: 80px; }
.translation-original, .translation-result {
flex: 1; padding: 12px 14px; font-size: 0.85rem; line-height: 1.6;
color: #334155; white-space: pre-wrap; max-height: 300px; overflow-y: auto;
}
.translation-original { border-right: 1px solid #e2e8f0; background: #fff; }
.translation-result { background: #fefce8; }
.translation-lang-label {
font-size: 0.7rem; color: #94a3b8; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px;
}
/* AI 解析区域 */
.ai-analysis-section {
margin-top: 14px; border: 1px solid #c4b5fd; border-radius: 10px;
overflow: hidden; background: #faf5ff;
}
.ai-analysis-header {
background: #ede9fe; padding: 8px 14px; font-size: 0.78rem; font-weight: 600;
color: #6d28d9; border-bottom: 1px solid #c4b5fd; display: flex; align-items: center; gap: 8px;
}
.ai-analysis-content { padding: 14px; font-size: 0.85rem; line-height: 1.7; color: #334155; }
.ai-analysis-content h4 { margin: 12px 0 6px 0; font-size: 0.82rem; font-weight: 700; color: #6d28d9; }
.ai-analysis-content ul { margin: 6px 0; padding-left: 20px; }
.ai-analysis-content li { margin-bottom: 4px; }
.status-done { color: #16a34a; font-weight: 600; }
.status-error { color: #dc2626; font-weight: 600; }
.progress-bar-wrap { background: #f1f5f9; border-radius: 8px; height: 8px; overflow: hidden; margin: 8px 0; }
.progress-bar-fill { background: linear-gradient(90deg, #667eea, #764ba2); height: 100%; transition: width 0.3s ease; border-radius: 8px; }
.empty-state { text-align: center; padding: 48px 24px; color: #94a3b8; }
.empty-state .icon { font-size: 3rem; margin-bottom: 12px; }
.stats-bar {
display: flex; gap: 20px; padding: 12px 20px; background: #f8fafc;
border-radius: 10px; border: 1px solid #e2e8f0; margin-bottom: 16px; flex-wrap: wrap;
}
.stat-item { display: flex; flex-direction: column; align-items: center; }
.stat-num { font-size: 1.4rem; font-weight: 700; color: #0f172a; }
.stat-lbl { font-size: 0.7rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }
.test-result-ok { color: #16a34a; font-weight: 600; padding: 6px 0; }
.test-result-err { color: #dc2626; font-weight: 600; padding: 6px 0; }
"""
# JavaScript:事件委托,从 data-copy-text 属性读取复制内容
COPY_JS = r"""
document.addEventListener('click', function(e) {
var btn = e.target.closest('.copy-btn');
if (!btn) return;
var text = btn.getAttribute('data-copy-text') || '';
if (!text) return;
navigator.clipboard.writeText(text).then(function() {
var orig = btn.innerText;
btn.innerText = '\u2705 \u5df2\u590d\u5236';
btn.classList.add('copied');
setTimeout(function() { btn.innerText = orig; btn.classList.remove('copied'); }, 2000);
}).catch(function() {
var ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.focus(); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
var orig = btn.innerText;
btn.innerText = '\u2705 \u5df2\u590d\u5236';
btn.classList.add('copied');
setTimeout(function() { btn.innerText = orig; btn.classList.remove('copied'); }, 2000);
});
});
"""
# ─────────────────────────────────────────────
# 工具函数
# ─────────────────────────────────────────────
CATEGORY_COLORS = cfg.CATEGORY_COLORS
CATEGORY_EMOJI = cfg.CATEGORY_EMOJI
def _esc_html(s: str) -> str:
"""HTML 转义(用于 data 属性值)"""
return s.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
def _badge_html(category: str) -> str:
color = CATEGORY_COLORS.get(category, "#94a3b8")
emoji = CATEGORY_EMOJI.get(category, "[邮件]")
return f'<span class="category-badge" style="background:{color}">{emoji} {category}</span>'
def _copy_btn_html(text: str, btn_id: str, label: str = "[复制]") -> str:
"""生成复制按钮(使用 data-copy-text 属性,无内联 JS)"""
safe = _esc_html(text)
return f'<button id="{btn_id}" class="copy-btn" data-copy-text="{safe}">{label}</button>'
def _render_translation_section(idx: int, translation) -> str:
"""渲染翻译对照区域(并排),无复制按钮"""
if not translation or not getattr(translation, "translated_text", ""):
return ""
src_lang = getattr(translation, "source_language", "Unknown")
translated = getattr(translation, "translated_text", "")
is_ok = getattr(translation, "is_already_target", False)
orig = getattr(translation, "original_text", "(无正文)")
if is_ok:
note = '<span style="color:#16a34a;font-size:0.75rem;">(原文已是目标语言)</span>'
else:
note = f'<span style="color:#94a3b8;font-size:0.75rem;">检测语言:{src_lang}</span>'
return (
'<div class="translation-section">'
' <div class="translation-header">'
f' [翻译] 邮件翻译对照 {note}'
' </div>'
' <div class="translation-columns">'
' <div class="translation-original">'
f' <div class="translation-lang-label">原文({src_lang})</div>'
f' <div>{orig}</div>'
' </div>'
' <div class="translation-result">'
' <div class="translation-lang-label">译文</div>'
f' <div>{translated}</div>'
' </div>'
' </div>'
'</div>'
)
def _render_ai_analysis_section(idx: int, analysis: str) -> str:
"""渲染 AI 深度解析区域"""
if not analysis:
return ""
import re
html = analysis
html = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html)
html = re.sub(r"\*(.+?)\*", r"<em>\1</em>", html)
html = html.replace("\n", "<br>")
return (
'<div class="ai-analysis-section">'
' <div class="ai-analysis-header">'
' [AI] AI 深度解析结果'
' </div>'
' <div class="ai-analysis-content">'
f' {html}'
' </div>'
'</div>'
)
def _render_email_card(
idx: int,
parsed,
result,
translation=None,
ai_analysis=None,
) -> str:
"""渲染单封邮件 HTML 卡片"""
if result is None:
status = '<span class="status-error">[warn] AI 分类跳过(未配置 API Key)</span>'
elif getattr(result, "error", ""):
status = f'<span class="status-error">[x] {getattr(result, "error", "")[:80]}</span>'
else:
status = '<span class="status-done">[ok] 分类完成</span>'
company = getattr(result, "company_name", "") if result and not getattr(result, "error", "") else "—"
category = getattr(result, "category", "") if result and not getattr(result, "error", "") else "—"
summary = getattr(result, "summary", "") if result and not getattr(result, "error", "") else ""
body = getattr(parsed, "body_text", "")
if body and len(body) > 800:
body_disp = body[:800] + "...(截断,完整内容见翻译对照)"
elif not body:
body_disp = "(正文为空)"
else:
body_disp = body
cid_c = f"cp-co-{idx}"
cid_b = f"cp-bo-{idx}"
cid_t = f"cp-ca-{idx}"
badge = _badge_html(category) if category != "—" else '<span style="color:#94a3b8">—</span>'
return (
'<div class="email-card">'
f' <div class="email-card-header">{badge} {status} <span class="email-filename">{getattr(parsed, "filename", "")}</span></div>'
' <div class="field-row">'
' <span class="field-label">公司名</span>'
f' <span class="field-value company">{company}</span>'
f' {_copy_btn_html(company, cid_c)}'
' </div>'
' <div class="field-row">'
' <span class="field-label">类型</span>'
f' <span class="field-value">{badge}</span>'
f' {_copy_btn_html(category, cid_t)}'
' </div>'
' <div class="field-row" style="align-items:flex-start;">'
' <span class="field-label">正文</span>'
f' <span class="field-value" style="white-space:pre-wrap;max-height:160px;overflow-y:auto;">{body_disp}</span>'
f' {_copy_btn_html(body, cid_b)}'
' </div>'
f' {("<div class=\"summary-text\">[tip] " + summary + "</div>") if summary else ""}'
' <div class="meta-row">'
f' <span>[sender] {getattr(parsed, "sender", "") or "—"}</span>'
f' <span>[date] {getattr(parsed, "date_str", "") or "—"}</span>'
f' <span>[subject] {(getattr(parsed, "subject", "") or "")[:50]}</span>'
' </div>'
f' {_render_translation_section(idx, translation)}'
f' {_render_ai_analysis_section(idx, ai_analysis)}'
'</div>'
)
def _render_results_html(
parsed_list: list,
results: list,
translations: list = None,
analyses: list = None,
) -> str:
if not parsed_list:
return '<div class="empty-state"><div class="icon">[doc]</div><p>上传 .msg 文件后,解析结果将在此展示</p></div>'
translations = translations or [None] * len(parsed_list)
analyses = analyses or [None] * len(parsed_list)
total = len(parsed_list)
done = sum(1 for r in results if r and not getattr(r, "error", ""))
err = total - done
cats = {}
for r in results:
if r and getattr(r, "category", ""):
c = getattr(r, "category", "")
cats[c] = cats.get(c, 0) + 1
cat_html = ""
for k, v in sorted(cats.items(), key=lambda x: -x[1]):
c = CATEGORY_COLORS.get(k, "#64748b")
cat_html += f'<div class="stat-item"><span class="stat-num" style="color:{c}">{v}</span><span class="stat-lbl">{k}</span></div>'
stats = (
'<div class="stats-bar">'
f'<div class="stat-item"><span class="stat-num">{total}</span><span class="stat-lbl">总封数</span></div>'
f'<div class="stat-item"><span class="stat-num" style="color:#16a34a">{done}</span><span class="stat-lbl">已分类</span></div>'
f'<div class="stat-item"><span class="stat-num" style="color:#dc2626">{err}</span><span class="stat-lbl">失败</span></div>'
f'{cat_html}'
'</div>'
)
cards = ""
for i, p in enumerate(parsed_list):
cards += _render_email_card(i, p, results[i], translations[i], analyses[i])
return f'<script>{COPY_JS}</script>{stats}{cards}'
# ─────────────────────────────────────────────
# 主处理函数
# ─────────────────────────────────────────────
def process_files(files, provider: str, api_key: str, model: str,
progress=gr.Progress(track_tqdm=False)):
"""解析 .msg + AI 分类"""
if not files:
return _render_results_html([], []), [], [], [], []
cfg.apply_runtime_config(provider.lower(), api_key.strip(), model.strip())
paths = []
for f in files:
if hasattr(f, "name"):
paths.append(f.name)
elif isinstance(f, str):
paths.append(f)
total = len(paths)
parsed_list = []
results = []
has_key = bool(cfg.get_llm_api_key())
progress(0, desc=f"[解析] 正在解析 {total} 封邮件…")
for i, fp in enumerate(paths):
progress((i + 0.5) / (total * 2), desc=f"[解析] 解析 {i+1}/{total}")
parsed_list.append(parse_msg_file(fp))
for i, p in enumerate(parsed_list):
progress(0.5 + (i + 0.5) / (total * 2), desc=f"[AI] 分类 {i+1}/{total}")
if not has_key:
results.append(None)
elif not getattr(p, "is_valid", True):
results.append(ClassificationResult(error=f"解析失败:{getattr(p, 'parse_error', '')}"))
else:
results.append(classify_email_sync(p))
progress(1.0, desc=f"[ok] 处理完成,共 {total} 封")
return _render_results_html(parsed_list, results), parsed_list, results, [], []
def translate_all(parsed_list, results, translations, target_lang: str,
progress=gr.Progress(track_tqdm=False)):
"""为所有已解析邮件翻译正文"""
if not parsed_list:
return _render_results_html([], []), [], []
new_t = list(translations) if translations else [None] * len(parsed_list)
total = len(parsed_list)
for i, p in enumerate(parsed_list):
progress(i / total, desc=f"[翻译] 翻译 {i+1}/{total}…")
body = getattr(p, "body_text", "")
if not body or not body.strip():
new_t[i] = None
continue
tr = translate_email_sync(body, target_lang)
tr.original_text = body[:2000] + ("…" if len(body) > 2000 else "")
new_t[i] = tr
progress(1.0, desc="[ok] 翻译完成")
return _render_results_html(parsed_list, results, new_t), new_t
def analyze_all(parsed_list, results, analyses,
progress=gr.Progress(track_tqdm=False)):
"""为所有已分类邮件做 AI 深度解析"""
if not parsed_list:
return _render_results_html([], []), [], []
import json as _json
new_a = list(analyses) if analyses else [None] * len(parsed_list)
total = len(parsed_list)
for i, p in enumerate(parsed_list):
progress(i / total, desc=f"[AI] 解析 {i+1}/{total}…")
body = getattr(p, "body_text", "")
if not body or not cfg.get_llm_api_key():
new_a[i] = None
continue
company = getattr(results[i], "company_name", "") if i < len(results) and results[i] else ""
category = getattr(results[i], "category", "") if i < len(results) and results[i] else ""
system_prompt = """You are a senior recruitment strategist and email analyst.
Analyze the recruitment-related email deeply and provide actionable insights.
You MUST respond with valid JSON only:
{
"intent_analysis": "<what the company is really saying>",
"suggested_action": "<what to do next>",
"tone_assessment": "<professional|friendly|cold|encouraging|standard>",
"key_phrases": ["<phrase1>", "<phrase2>"],
"follow_up_template": "<draft follow-up if appropriate, else empty>"
}"""
user_prompt = (
f"Company: {company}\n"
f"Category: {category}\n"
f"From: {getattr(p, 'sender', '')}\n"
f"Subject: {getattr(p, 'subject', '')}\n"
f"\nBody:\n{body[:3000]}\n"
f"\nRespond in JSON format as specified."
)
try:
from openai import AsyncOpenAI
import asyncio as _a
api_key = cfg.get_llm_api_key()
model = cfg.get_llm_model()
base_url = cfg.get_llm_base_url()
ck = {"api_key": api_key}
if base_url:
ck["base_url"] = base_url
client = AsyncOpenAI(**ck)
async def _call():
resp = await client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.2,
max_tokens=1024,
response_format={"type": "json_object"},
)
return resp.choices[0].message.content or ""
loop = _a.new_event_loop()
content = loop.run_until_complete(_call())
loop.close()
data = _json.loads(content)
parts = []
if data.get("intent_analysis"):
parts.append(f'<h4>[意图] 意图分析</h4><p>{data["intent_analysis"]}</p>')
if data.get("suggested_action"):
parts.append(f'<h4>[行动] 建议行动</h4><p>{data["suggested_action"]}</p>')
if data.get("tone_assessment"):
_tm = {"professional": "专业正式", "friendly": "友好亲切", "cold": "冷淡敷衍", "encouraging": "鼓励积极", "standard": "标准常规"}
_t = _tm.get(data["tone_assessment"], data["tone_assessment"])
parts.append(f'<h4>[语气] 语气评估</h4><p>{_t}</p>')
if data.get("key_phrases"):
_ph = "".join(f"<li>{x}</li>" for x in data["key_phrases"])
parts.append(f"<h4>[关键词] 关键短语</h4><ul>{_ph}</ul>")
if data.get("follow_up_template"):
parts.append(
f'<h4>[模板] 跟进邮件模板</h4>'
f'<pre style="background:#f1f5f9;padding:10px;border-radius:6px;'
f'font-size:0.82rem;white-space:pre-wrap;">{data["follow_up_template"]}</pre>'
)
new_a[i] = "\n".join(parts) if parts else "(无内容)"
except Exception as e:
logger.error(f"AI 解析失败: {e}")
new_a[i] = f"[warn] 解析失败:{str(e)[:100]}"
progress(1.0, desc="[ok] AI 解析完成")
return _render_results_html(parsed_list, results, None, new_a), new_a
# ─────────────────────────────────────────────
# 连接测试
# ─────────────────────────────────────────────
def test_llm_connection(provider: str, api_key: str, model: str) -> str:
if not api_key.strip():
return "[x] 请先填写 API Key"
cfg.apply_runtime_config(provider.lower(), api_key.strip(), model.strip())
try:
import openai
burl = cfg.get_llm_base_url()
ck = {"api_key": api_key.strip()}
if burl:
ck["base_url"] = burl
client = openai.OpenAI(**ck)
resp = client.chat.completions.create(
model=cfg.get_llm_model(),
messages=[{"role": "user", "content": "Reply with just: OK"}],
max_tokens=10,
temperature=0,
)
reply = resp.choices[0].message.content.strip()
return f'<span class="test-result-ok">[ok] 连接成功!模型回复:{reply}</span>'
except Exception as e:
return f'<span class="test-result-err">[x] 连接失败:{str(e)[:120]}</span>'
# ─────────────────────────────────────────────
# Gradio 界面构建
# ─────────────────────────────────────────────
def build_app() -> gr.Blocks:
curr = cfg.get_current_config()
with gr.Blocks(
title="邮件分析查看器",
css=CUSTOM_CSS,
theme=gr.themes.Soft(
primary_hue=gr.themes.colors.violet,
neutral_hue=gr.themes.colors.slate,
),
) as demo:
parsed_st = gr.State([])
results_st = gr.State([])
trans_st = gr.State([])
analyses_st = gr.State([])
gr.HTML(
'<div class="header-banner">'
'<h1>[邮件] 邮件解析 & AI 智能分析查看器</h1>'
'<p>上传 .msg 邮件文件,AI 自动提取 / 分类 / 翻译 / 深度解析</p>'
'</div>'
)
with gr.Accordion("⚙️ API 配置(展开设置 LLM 密钥)",
open=not bool(curr["api_key"]),
elem_classes="config-panel"):
with gr.Row():
prov_dd = gr.Dropdown(
choices=["deepseek", "openai"],
value=curr["provider"],
label="LLM 提供商",
scale=1,
)
key_box = gr.Textbox(
value=curr["api_key"],
label="API Key",
type="password",
scale=3,
)
mdl_box = gr.Textbox(
value=curr["model"],
label="模型名称",
scale=2,
)
with gr.Row():
tst_btn = gr.Button("🔌 测试连接", variant="secondary", size="sm")
tst_res = gr.HTML(label="")
tst_btn.click(test_llm_connection, [prov_dd, key_box, mdl_box], tst_res)
gr.HTML("<br>")
with gr.Row():
with gr.Column(scale=1):
upl = gr.File(
label="[上传] 拖拽或点击上传 .msg 文件(支持多文件)",
file_types=[".msg"],
file_count="multiple",
height=180,
)
go_btn = gr.Button(
"[开始] 开始解析并分类",
variant="primary",
size="lg",
)
with gr.Row():
tgt_lang = gr.Dropdown(
choices=["Chinese", "English", "Japanese", "German", "French"],
value="Chinese",
label="[翻译] 翻译目标语言",
scale=2,
)
tr_btn = gr.Button(
"[翻译] 翻译全部邮件正文",
variant="secondary",
size="sm",
)
an_btn = gr.Button(
"[AI] AI 深度解析全部邮件",
variant="secondary",
size="sm",
)
gr.HTML(
'<div style="font-size:0.8rem;color:#94a3b8;margin-top:8px;line-height:1.6;">'
"[功能] 公司名 / 类型 / 正文 支持一键复制 · "
"翻译并排对照 · AI 深度解析(意图 / 行动 / 语气 / 模板)"
'</div>'
)
gr.HTML("<br>")
res_html = gr.HTML(
value=(
'<div class="empty-state">'
'<div class="icon">[doc]</div>'
'<p>上传 .msg 文件并点击「开始解析」后,结果将在此展示</p>'
'</div>'
),
label="",
)
# 按钮回调
go_btn.click(
process_files,
[upl, prov_dd, key_box, mdl_box],
[res_html, parsed_st, results_st, trans_st, analyses_st],
)
tr_btn.click(
translate_all,
[parsed_st, results_st, trans_st, tgt_lang],
[res_html, trans_st],
)
an_btn.click(
analyze_all,
[parsed_st, results_st, analyses_st],
[res_html, analyses_st],
)
gr.HTML(
'<div style="text-align:center;padding:24px 0 8px;'
'color:#cbd5e1;font-size:0.78rem;">'
'基于 extract-msg · AI 功能基于 OpenAI / DeepSeek'
'</div>'
)
return demo
# ─────────────────────────────────────────────
# 启动
# ─────────────────────────────────────────────
if __name__ == "__main__":
app = build_app()
app.launch(
server_name="0.0.0.0",
server_port=int(os.getenv("PORT", 7860)),
show_api=False,
share=False, # HF Spaces:不需要 public tunnel
debug=False, # 生产环境关闭 debug
)