boyuu commited on
Commit
5c5ef88
·
verified ·
1 Parent(s): ffdbdfc

Make Space public and speed up initial UI

Browse files
Files changed (5) hide show
  1. README.md +7 -7
  2. app.py +13 -18
  3. requirements.txt +0 -1
  4. router.py +1 -57
  5. 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
- 界面启动和点击「刷新价格」时从 [OpenRouter 模型目录 API](https://openrouter.ai/docs/api/api-reference/models/get-models)读取报价。网络不可用时明确显示参考日期,不将旧价格标为实时。
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
- | Word | `.doc`, `.docx` | LibreOffice 转 PDF 后交给模型,保留页面、图片和表格 |
45
- | PowerPoint | `.ppt`, `.pptx` | LibreOffice 转 PDF,按幻灯片页面阅读;不包含讲者备注、动画或隐藏页 |
46
- | Excel | `.xls`, `.xlsx`, `.csv` | 按工作表、行号读取单元格文本;XLSX 保留公式,XLS 使用已保存的值 |
47
- | 图片 | `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.bmp`, `.tif`, `.tiff` | 单帧图片转 JPEG,最长边缩放至 2048px,发送视觉输入 |
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 模型目录、价格、API 请求、引用和费用
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 private, server-side OpenRouter Space."""
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
- default_choices, price_catalog, stream_reply, usage_summary)
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(default_choices(), value=DEFAULT_MODEL, label="选择模型 · 美元 / 百万 tokens")
164
  search = gr.Checkbox(value=True, label="开启联网搜索", info="模型按需检索;搜索另行计费。")
165
- files = gr.File(label="上传文档 / 图片", file_count="multiple", file_types=ALLOWED, type="filepath")
166
- gr.Markdown("PDF · Word(doc/docx)· PPT(ppt/pptx)· Excel(xls/xlsx/csv)· 图片\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.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, mcp_server=True,
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 live model pricing. No credentials in UI or logs."""
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", "prices"], required=True)
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 == "prices":
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]