Spaces:
Sleeping
Sleeping
Make Space public and speed up initial UI
Browse files- README.md +7 -7
- app.py +13 -18
- requirements.txt +0 -1
- router.py +1 -57
- scripts/smoke_remote.py +2 -6
README.md
CHANGED
|
@@ -16,9 +16,9 @@ startup_duration_timeout: 30m
|
|
| 16 |
|
| 17 |
Hugging Face Gradio Space,通过 **OpenRouter** 调用 Claude。支持中文多轮聊天、流式输出、联网搜索和文档/图片上传,无需下载模型权重。
|
| 18 |
|
| 19 |
-
## 模型
|
| 20 |
|
| 21 |
-
界面
|
| 22 |
|
| 23 |
2026-10-06 核实的价格(美元 / 每百万 tokens):
|
| 24 |
|
|
@@ -41,10 +41,10 @@ Hugging Face Gradio Space,通过 **OpenRouter** 调用 Claude。支持中文
|
|
| 41 |
| 类别 | 扩展名 | 处理方式 |
|
| 42 |
|---|---|---|
|
| 43 |
| PDF | `.pdf` | Base64 文件交给 Claude 原生阅读,支持扫描页和图表 |
|
| 44 |
-
|
|
| 45 |
-
|
|
| 46 |
-
|
|
| 47 |
-
|
|
| 48 |
|
| 49 |
Excel 不执行公式,不分析嵌入图表/图片;需要视觉分析时请另存为 PDF 或上传截图。多帧 GIF/TIFF 明确拒绝,避免只读首帧而遗漏内容。Office 转换可能改变字体、分页;对版式要求严格时建议直接上传原始 PDF。加密文件需先移除密码。
|
| 50 |
|
|
@@ -102,7 +102,7 @@ API Key 只在服务器读取,浏览器不会收到。上传文件在 Space
|
|
| 102 |
|
| 103 |
```text
|
| 104 |
app.py Gradio 界面、会话管理与流式显示
|
| 105 |
-
router.py 模型
|
| 106 |
attachments.py PDF / Office / Excel / 图片处理
|
| 107 |
requirements.txt Python 依赖(Gradio 由平台管理)
|
| 108 |
packages.txt 系统依赖(LibreOffice、中文字体)
|
|
|
|
| 16 |
|
| 17 |
Hugging Face Gradio Space,通过 **OpenRouter** 调用 Claude。支持中文多轮聊天、流式输出、联网搜索和文档/图片上传,无需下载模型权重。
|
| 18 |
|
| 19 |
+
## 支持的模型
|
| 20 |
|
| 21 |
+
为缩短首屏加载时间,界面不再请求 OpenRouter 模型目录,也不显示价格面板。每次调用后仍会显示 OpenRouter 实际返回的 token 用量和费用;未返回费用时提示到 Activity 查看。
|
| 22 |
|
| 23 |
2026-10-06 核实的价格(美元 / 每百万 tokens):
|
| 24 |
|
|
|
|
| 41 |
| 类别 | 扩展名 | 处理方式 |
|
| 42 |
|---|---|---|
|
| 43 |
| PDF | `.pdf` | Base64 文件交给 Claude 原生阅读,支持扫描页和图表 |
|
| 44 |
+
| WORD | `.DOC`, `.DOCX` | LibreOffice 转 PDF 后交给模型,保留页面、图片和表格 |
|
| 45 |
+
| POWERPOINT | `.PPT`, `.PPTX` | LibreOffice 转 PDF,按幻灯片页面阅读;不包含讲者备注、动画或隐藏页 |
|
| 46 |
+
| EXCEL | `.XLS`, `.XLSX`, `.CSV` | 按工作表、行号读取单元格文本;XLSX 保留公式,XLS 使用已保存的值 |
|
| 47 |
+
| IMAGES | `.PNG`, `.JPG`, `.JPEG`, `.WEBP`, `.GIF`, `.BMP`, `.TIF`, `.TIFF` | 单帧图片转 JPEG,最长边缩放至 2048px,发送视觉输入 |
|
| 48 |
|
| 49 |
Excel 不执行公式,不分析嵌入图表/图片;需要视觉分析时请另存为 PDF 或上传截图。多帧 GIF/TIFF 明确拒绝,避免只读首帧而遗漏内容。Office 转换可能改变字体、分页;对版式要求严格时建议直接上传原始 PDF。加密文件需先移除密码。
|
| 50 |
|
|
|
|
| 102 |
|
| 103 |
```text
|
| 104 |
app.py Gradio 界面、会话管理与流式显示
|
| 105 |
+
router.py 模型配置、API 请求、引用和费用
|
| 106 |
attachments.py PDF / Office / Excel / 图片处理
|
| 107 |
requirements.txt Python 依赖(Gradio 由平台管理)
|
| 108 |
packages.txt 系统依赖(LibreOffice、中文字体)
|
app.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Claude Document Studio — a
|
| 2 |
|
| 3 |
import copy
|
| 4 |
import json
|
|
@@ -7,6 +7,9 @@ import time
|
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
from pathlib import Path
|
| 9 |
|
|
|
|
|
|
|
|
|
|
| 10 |
# Optional ZeroGPU registration for eligible free HF accounts. Never invoked by chat.
|
| 11 |
if os.environ.get("USE_ZEROGPU") == "1":
|
| 12 |
import spaces
|
|
@@ -20,7 +23,7 @@ import gradio as gr
|
|
| 20 |
|
| 21 |
from attachments import ALLOWED, FileError, prepare_files
|
| 22 |
from router import (DEFAULT_MODEL, MODELS, RouterError, build_payload, citations_markdown,
|
| 23 |
-
|
| 24 |
|
| 25 |
CSS = """
|
| 26 |
.gradio-container { max-width: 1240px !important; margin: auto; }
|
|
@@ -31,12 +34,7 @@ CSS = """
|
|
| 31 |
"""
|
| 32 |
MAX_REQUEST_BYTES = 28 * 1024 * 1024
|
| 33 |
MAX_TURNS = 40
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
def refresh_prices(model: str) -> tuple:
|
| 37 |
-
"""Refresh the three model prices from OpenRouter's public catalog."""
|
| 38 |
-
choices, table = price_catalog(force=True)
|
| 39 |
-
return gr.update(choices=choices, value=model if model in MODELS else DEFAULT_MODEL), table
|
| 40 |
|
| 41 |
|
| 42 |
def chat(text: str, files: list[str] | None, session: dict, model: str,
|
|
@@ -160,19 +158,18 @@ def create_app() -> gr.Blocks:
|
|
| 160 |
session = gr.State({"messages": [], "display": [], "pages": 0}, time_to_live=3600)
|
| 161 |
with gr.Row():
|
| 162 |
with gr.Column(scale=1, min_width=290):
|
| 163 |
-
model = gr.Dropdown(
|
| 164 |
search = gr.Checkbox(value=True, label="开启联网搜索", info="模型按需检索;搜索另行计费。")
|
| 165 |
-
files = gr.File(label="上传
|
| 166 |
-
|
|
|
|
|
|
|
| 167 |
"每次 ≤ 8 个文件,单文件 ≤ 15 MB,总计 ≤ 20 MB;会话 PDF ≤ 100 页。"
|
| 168 |
-
"
|
| 169 |
with gr.Accordion("生成设置", open=False):
|
| 170 |
effort = gr.Dropdown([("低", "low"), ("中", "medium"), ("高", "high"),
|
| 171 |
("更高", "xhigh"), ("最高", "max")], value="medium", label="推理强度")
|
| 172 |
max_tokens = gr.Slider(1024, 32768, value=8192, step=1024, label="输出上限(含推理 tokens)")
|
| 173 |
-
with gr.Accordion("模型价格", open=True):
|
| 174 |
-
prices = gr.Markdown("正在加载价格…")
|
| 175 |
-
refresh = gr.Button("刷新价格", size="sm")
|
| 176 |
with gr.Column(scale=3, min_width=440):
|
| 177 |
chatbot = gr.Chatbot(height=540, label="对话", layout="bubble")
|
| 178 |
prompt = gr.Textbox(label="输入问题", placeholder="输入问题,或上传文件后让我分析…", lines=3)
|
|
@@ -192,8 +189,6 @@ def create_app() -> gr.Blocks:
|
|
| 192 |
stop.click(stop_chat, inputs=[session], outputs=[chatbot, prompt, files, status, send, clear],
|
| 193 |
cancels=[event], queue=False, api_name=False)
|
| 194 |
clear.click(clear_chat, outputs=outputs, api_name=False)
|
| 195 |
-
refresh.click(refresh_prices, inputs=[model], outputs=[model, prices], api_name="prices")
|
| 196 |
-
demo.load(refresh_prices, inputs=[model], outputs=[model, prices], api_name=False)
|
| 197 |
return demo
|
| 198 |
|
| 199 |
|
|
@@ -206,7 +201,7 @@ if __name__ == "__main__":
|
|
| 206 |
demo.queue(max_size=16).launch(
|
| 207 |
server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")),
|
| 208 |
theme=gr.themes.Soft(primary_hue="indigo"), css=CSS,
|
| 209 |
-
auth=auth, max_file_size="15mb", show_error=False,
|
| 210 |
run_history=False, state_session_capacity=100,
|
| 211 |
allowed_paths=[], blocked_paths=[str(Path(__file__).parent / ".env")],
|
| 212 |
)
|
|
|
|
| 1 |
+
"""Claude Document Studio — a server-side OpenRouter Space."""
|
| 2 |
|
| 3 |
import copy
|
| 4 |
import json
|
|
|
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
+
# Avoid a nonessential analytics request during initial page load.
|
| 11 |
+
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
|
| 12 |
+
|
| 13 |
# Optional ZeroGPU registration for eligible free HF accounts. Never invoked by chat.
|
| 14 |
if os.environ.get("USE_ZEROGPU") == "1":
|
| 15 |
import spaces
|
|
|
|
| 23 |
|
| 24 |
from attachments import ALLOWED, FileError, prepare_files
|
| 25 |
from router import (DEFAULT_MODEL, MODELS, RouterError, build_payload, citations_markdown,
|
| 26 |
+
stream_reply, usage_summary)
|
| 27 |
|
| 28 |
CSS = """
|
| 29 |
.gradio-container { max-width: 1240px !important; margin: auto; }
|
|
|
|
| 34 |
"""
|
| 35 |
MAX_REQUEST_BYTES = 28 * 1024 * 1024
|
| 36 |
MAX_TURNS = 40
|
| 37 |
+
MODEL_CHOICES = [(details[0], model_id) for model_id, details in MODELS.items()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def chat(text: str, files: list[str] | None, session: dict, model: str,
|
|
|
|
| 158 |
session = gr.State({"messages": [], "display": [], "pages": 0}, time_to_live=3600)
|
| 159 |
with gr.Row():
|
| 160 |
with gr.Column(scale=1, min_width=290):
|
| 161 |
+
model = gr.Dropdown(MODEL_CHOICES, value=DEFAULT_MODEL, label="选择模型")
|
| 162 |
search = gr.Checkbox(value=True, label="开启联网搜索", info="模型按需检索;搜索另行计费。")
|
| 163 |
+
files = gr.File(label="上传 PDF / WORD / POWERPOINT / EXCEL / IMAGES",
|
| 164 |
+
file_count="multiple", file_types=ALLOWED, type="filepath")
|
| 165 |
+
gr.Markdown("PDF · WORD (DOC/DOCX) · POWERPOINT (PPT/PPTX) · "
|
| 166 |
+
"EXCEL (XLS/XLSX/CSV) · IMAGES\n\n"
|
| 167 |
"每次 ≤ 8 个文件,单文件 ≤ 15 MB,总计 ≤ 20 MB;会话 PDF ≤ 100 页。"
|
| 168 |
+
"EXCEL 读取单元格和公式,不读取嵌入图表。")
|
| 169 |
with gr.Accordion("生成设置", open=False):
|
| 170 |
effort = gr.Dropdown([("低", "low"), ("中", "medium"), ("高", "high"),
|
| 171 |
("更高", "xhigh"), ("最高", "max")], value="medium", label="推理强度")
|
| 172 |
max_tokens = gr.Slider(1024, 32768, value=8192, step=1024, label="输出上限(含推理 tokens)")
|
|
|
|
|
|
|
|
|
|
| 173 |
with gr.Column(scale=3, min_width=440):
|
| 174 |
chatbot = gr.Chatbot(height=540, label="对话", layout="bubble")
|
| 175 |
prompt = gr.Textbox(label="输入问题", placeholder="输入问题,或上传文件后让我分析…", lines=3)
|
|
|
|
| 189 |
stop.click(stop_chat, inputs=[session], outputs=[chatbot, prompt, files, status, send, clear],
|
| 190 |
cancels=[event], queue=False, api_name=False)
|
| 191 |
clear.click(clear_chat, outputs=outputs, api_name=False)
|
|
|
|
|
|
|
| 192 |
return demo
|
| 193 |
|
| 194 |
|
|
|
|
| 201 |
demo.queue(max_size=16).launch(
|
| 202 |
server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")),
|
| 203 |
theme=gr.themes.Soft(primary_hue="indigo"), css=CSS,
|
| 204 |
+
auth=auth, max_file_size="15mb", show_error=False,
|
| 205 |
run_history=False, state_session_capacity=100,
|
| 206 |
allowed_paths=[], blocked_paths=[str(Path(__file__).parent / ".env")],
|
| 207 |
)
|
requirements.txt
CHANGED
|
@@ -4,4 +4,3 @@ pypdf>=6.0,<7
|
|
| 4 |
openpyxl>=3.1.5,<4
|
| 5 |
xlrd>=2.0.2,<3
|
| 6 |
defusedxml>=0.7.1,<1
|
| 7 |
-
mcp>=1.0,<2
|
|
|
|
| 4 |
openpyxl>=3.1.5,<4
|
| 5 |
xlrd>=2.0.2,<3
|
| 6 |
defusedxml>=0.7.1,<1
|
|
|
router.py
CHANGED
|
@@ -1,12 +1,7 @@
|
|
| 1 |
-
"""OpenRouter transport and
|
| 2 |
|
| 3 |
-
import copy
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
-
import threading
|
| 7 |
-
import time
|
| 8 |
-
from datetime import datetime, timezone
|
| 9 |
-
from decimal import Decimal
|
| 10 |
from urllib.parse import urlsplit
|
| 11 |
|
| 12 |
import httpx
|
|
@@ -18,63 +13,12 @@ MODELS = {
|
|
| 18 |
"anthropic/claude-fable-5.1": ("Claude Fable 5.1", "10", "50"),
|
| 19 |
}
|
| 20 |
DEFAULT_MODEL = "anthropic/claude-sonnet-5.5"
|
| 21 |
-
_cache = {"data": {}, "time": 0.0, "updated": ""}
|
| 22 |
-
_lock = threading.Lock()
|
| 23 |
|
| 24 |
|
| 25 |
class RouterError(Exception):
|
| 26 |
"""A safe, user-facing error without request headers or document content."""
|
| 27 |
|
| 28 |
|
| 29 |
-
def price_catalog(force: bool = False) -> tuple[list, str]:
|
| 30 |
-
"""Refresh public prices; label dated fallback prices when offline."""
|
| 31 |
-
error = False
|
| 32 |
-
with _lock:
|
| 33 |
-
if force or time.monotonic() - _cache["time"] > 900 or not _cache["time"]:
|
| 34 |
-
try:
|
| 35 |
-
response = httpx.get(f"{API_BASE}/models", timeout=15)
|
| 36 |
-
response.raise_for_status()
|
| 37 |
-
data = {m["id"]: m for m in response.json()["data"] if m["id"] in MODELS}
|
| 38 |
-
_cache.update(data=data, time=time.monotonic(),
|
| 39 |
-
updated=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"))
|
| 40 |
-
except (httpx.HTTPError, ValueError, KeyError, TypeError):
|
| 41 |
-
error = True
|
| 42 |
-
data = copy.deepcopy(_cache["data"])
|
| 43 |
-
updated = _cache["updated"]
|
| 44 |
-
rows, choices = [], []
|
| 45 |
-
for model_id, (name, fallback_in, fallback_out) in MODELS.items():
|
| 46 |
-
m = data.get(model_id)
|
| 47 |
-
try:
|
| 48 |
-
p = m["pricing"]
|
| 49 |
-
inp = format((Decimal(p["prompt"]) * 1_000_000).normalize(), "f")
|
| 50 |
-
out = format((Decimal(p["completion"]) * 1_000_000).normalize(), "f")
|
| 51 |
-
search = f'${Decimal(p["web_search"]):g}/次' if p.get("web_search") else "按搜索引擎计费"
|
| 52 |
-
source = "目录报价"
|
| 53 |
-
except (KeyError, TypeError, ArithmeticError):
|
| 54 |
-
inp, out, search = fallback_in, fallback_out, "$0.01/次(参考)"
|
| 55 |
-
source = "2026-10-06 参考价"
|
| 56 |
-
if m is None and updated:
|
| 57 |
-
source += ";当前目录未列出"
|
| 58 |
-
choices.append((f"{name} · 输入 ${inp} / 输出 ${out}", model_id))
|
| 59 |
-
rows.append(f"| [{name}](https://openrouter.ai/{model_id}) | ${inp} | ${out} | {search} | {source} |")
|
| 60 |
-
note = f"目录更新时间:{updated}" if updated else "暂未取得实时目录,显示 2026-10-06 已核实的参考价格。"
|
| 61 |
-
if error:
|
| 62 |
-
note += " · 刷新失败,保留上次价格。"
|
| 63 |
-
table = "\n".join([
|
| 64 |
-
"**美元 / 每百万 tokens**,搜索按次另计。", "",
|
| 65 |
-
"| 模型 | 输入 / 1M | 输出 / 1M | 原生搜索 | 价格来源 |",
|
| 66 |
-
"|---|---:|---:|---|---|", *rows, "", note,
|
| 67 |
-
"", "搜索默认使用原生引擎;供应商不支持时 OpenRouter 可回退到 Exa,按该引擎费率结算。"
|
| 68 |
-
"图片、PDF、搜索结果和历史消息均会消耗输入 tokens;推理 tokens 计入输出。"
|
| 69 |
-
"实际账单以 OpenRouter usage 和供应商费率为准。",
|
| 70 |
-
])
|
| 71 |
-
return choices, table
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def default_choices() -> list:
|
| 75 |
-
return [(f"{name} · 输入 ${i} / 输出 ${o}", model) for model, (name, i, o) in MODELS.items()]
|
| 76 |
-
|
| 77 |
-
|
| 78 |
def build_payload(messages: list, model: str, search: bool, max_tokens: int, effort: str) -> dict:
|
| 79 |
if model not in MODELS:
|
| 80 |
raise RouterError("请选择列表中的模型。")
|
|
|
|
| 1 |
+
"""OpenRouter transport and model configuration. No credentials in UI or logs."""
|
| 2 |
|
|
|
|
| 3 |
import json
|
| 4 |
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from urllib.parse import urlsplit
|
| 6 |
|
| 7 |
import httpx
|
|
|
|
| 13 |
"anthropic/claude-fable-5.1": ("Claude Fable 5.1", "10", "50"),
|
| 14 |
}
|
| 15 |
DEFAULT_MODEL = "anthropic/claude-sonnet-5.5"
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class RouterError(Exception):
|
| 19 |
"""A safe, user-facing error without request headers or document content."""
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def build_payload(messages: list, model: str, search: bool, max_tokens: int, effort: str) -> dict:
|
| 23 |
if model not in MODELS:
|
| 24 |
raise RouterError("请选择列表中的模型。")
|
scripts/smoke_remote.py
CHANGED
|
@@ -26,7 +26,7 @@ def main() -> None:
|
|
| 26 |
parser = argparse.ArgumentParser(description=__doc__)
|
| 27 |
parser.add_argument("repo_id")
|
| 28 |
parser.add_argument("--fixtures", type=Path)
|
| 29 |
-
parser.add_argument("--case", choices=["models", "files", "search", "errors"
|
| 30 |
args = parser.parse_args()
|
| 31 |
client = Client(args.repo_id, token=get_token(), httpx_kwargs={"timeout": 360}, verbose=False)
|
| 32 |
|
|
@@ -36,11 +36,7 @@ def main() -> None:
|
|
| 36 |
print(json.dumps({"model": model, "answer": answer[:2000], "status": result[-1]}, ensure_ascii=False), flush=True)
|
| 37 |
return answer, result
|
| 38 |
|
| 39 |
-
if args.case == "
|
| 40 |
-
result = client.predict("anthropic/claude-sonnet-5.5", api_name="/prices")
|
| 41 |
-
assert all(name in result[-1] for name in ["Opus 5.5", "Sonnet 5.5", "Fable 5.1"])
|
| 42 |
-
print(result[-1], flush=True)
|
| 43 |
-
elif args.case == "models":
|
| 44 |
for slug in ["sonnet-5.5", "opus-5.5", "fable-5.1"]:
|
| 45 |
answer, result = chat("This is a connectivity test. Reply with exactly CONNECTED.", model="anthropic/claude-" + slug)
|
| 46 |
assert "CONNECTED" in answer, result[-1]
|
|
|
|
| 26 |
parser = argparse.ArgumentParser(description=__doc__)
|
| 27 |
parser.add_argument("repo_id")
|
| 28 |
parser.add_argument("--fixtures", type=Path)
|
| 29 |
+
parser.add_argument("--case", choices=["models", "files", "search", "errors"], required=True)
|
| 30 |
args = parser.parse_args()
|
| 31 |
client = Client(args.repo_id, token=get_token(), httpx_kwargs={"timeout": 360}, verbose=False)
|
| 32 |
|
|
|
|
| 36 |
print(json.dumps({"model": model, "answer": answer[:2000], "status": result[-1]}, ensure_ascii=False), flush=True)
|
| 37 |
return answer, result
|
| 38 |
|
| 39 |
+
if args.case == "models":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
for slug in ["sonnet-5.5", "opus-5.5", "fable-5.1"]:
|
| 41 |
answer, result = chat("This is a connectivity test. Reply with exactly CONNECTED.", model="anthropic/claude-" + slug)
|
| 42 |
assert "CONNECTED" in answer, result[-1]
|