为什么需要本地脚本?
所有 v6.x 公开演示版都运行在 zgdgt.cn 公开域名下。出于安全合规,公网页面里不内置任何 API 凭据,
也不直接连接第三方服务的服务器。两个例外场景需要本地脚本:
- 使用 IMA 知识库:浏览器无法跨域访问
ima.qq.com/openapi/wiki/v1,需要在本地启一个 HTTP 代理做转发。 访客在页内输入 ima Client ID + API Key,由本地代理代为请求。 - 本机离线运行 v6.8:3D 摄像头功能在
https://zgdgt.cn/已可直接使用。 想完全离线(无 HTTPS / 文件协议直接打开)调试时,可通过启动脚本在本地起一个允许getUserMedia的服务。
两个脚本均不收集、不上传访客数据,凭据仅留存在浏览器和/或访客本地 daemon 内存中。
IMA 本地代理(ima-local-proxy.py)
一个 100% Python 标准库实现的 HTTP 代理,监听 127.0.0.1:8766,
只把白名单端点 search_knowledge_base / search_knowledge 转发到
https://ima.qq.com/openapi/wiki/v1。请求体里不含 CORS 敏感的 Authorization:
IMA 用 ima-openapi-clientid + ima-openapi-apikey 两个自定义头,代理直接转发,不解析、不缓存。
| 默认监听 | http://127.0.0.1:8766(可由环境变量 IMA_PROXY_PORT 覆盖) |
|---|---|
| 运行环境 | Python 3.7+(仅用 http.server / urllib,零依赖) |
| 白名单端点 | search_knowledge_base、search_knowledge |
| 请求体上限 | 1 MiB(超过返回 413) |
| 凭据存储 | 不留盘;只在本次请求转发里携带 |
| 健康检查 | 访问 http://127.0.0.1:8766/health,返回 JSON {"ok":true,"service":"ima-local-proxy",...} |
启动步骤(Windows / macOS / Linux 通用)
看到 IMA 本地桥梁已启动:http://127.0.0.1:8766/api/ima 即成功;关闭窗口即停止服务。
安全提示
① 代理只监听 127.0.0.1,外网无法访问(除非你显式改成 0.0.0.0,但不建议)。
② 需要填的 ima Client ID / API Key 只在该次请求里用到, 代理不缓存、不记日志、不写到磁盘;但你仍应保管好来源凭据。
③ 启动后浏览器会自动允许跨域(Access-Control-Allow-Origin: *),
这意味着代理启动期间任何访问 zgdgt.cn 的浏览器请求都能直接命中本地服务,用于课程用途无问题。
在课程AI助手里配合使用
① 进入 v6.4 / v6.6 / v6.7 / v6.8 任一公开演示版 → ② 切到「📒 知识库问答」页签 → ③ 连接模式保持「本地代理」 → ④ 输入你的 ima 凭据 → ⑤ 提问即可。
源代码预览
1class=class="s">"c">#!/usr/bin/env python3 2class=class="s">"c"># -*- coding: utf-class="n">8 -*- 3"""IMA OpenAPI 本地桥梁 4用途:与《课程AI助手(v4.class="n">4)》配套,将浏览器中的 ima 请求从 class="n">127.0.class="n">0.1 转发到 ima.qq.com,解决浏览器 CORS 限制。 5安全边界:只白名单转发两个检索端点;不把凭据写入磁盘,不记录请求内容。 6启动:python ima-local-proxy.py (默认 http://class="n">127.0.class="n">0.1:class="n">8766) 7""" 8from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 9import json 10import os 11import sys 12import urllib.error 13import urllib.parse 14import urllib.request 15 16HOST = "class="n">127.0.class="n">0.1" 17PORT = int(os.environ.get("IMA_PROXY_PORT", "class="n">8766")) 18IMA_BASE = "https://ima.qq.com/openapi/wiki/v1" 19ALLOWED_ENDPOINTS = {"search_knowledge_base", "search_knowledge"} 20 21 22def cors_headers(handler, content_type="application/json; charset=utf-class="n">8"): 23 handler.send_header("Access-Control-Allow-Origin", "*") 24 handler.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") 25 handler.send_header("Access-Control-Allow-Headers", "Content-Type, ima-openapi-clientid, ima-openapi-apikey") 26 handler.send_header("Access-Control-Max-Age", "class="n">86400") 27 handler.send_header("Cache-Control", "no-store") 28 handler.send_header("Content-Type", content_type) 29 30 31class IMAProxyHandler(BaseHTTPRequestHandler): 32 server_version = "LingxiIMAProxy/class="n">1.0" 33 34 def log_message(self, fmt, *args): 35 class=class="s">"c"># 只记录方法和状态,不记录 Authorization / ima 凭据 / 查询正文 36 sys.stderr.write("[ima-proxy] %s\n" % (fmt % args)) 37 38 def do_OPTIONS(self): 39 self.send_response(class="n">204) 40 cors_headers(self, "text/plain; charset=utf-class="n">8") 41 self.end_headers() 42 43 def do_GET(self): 44 path = urllib.parse.urlparse(self.path).path 45 if path == "/health": 46 self._json(class="n">200, {"ok": True, "service": "ima-local-proxy", "allowed": sorted(ALLOWED_ENDPOINTS)}) 47 return 48 if path == "/": 49 text = ( 50 "<!doctype html><meta charset=&class=class="s">"c">#x27;utf-class="n">8'><title>IMA 本地桥梁</title>" 51 "<style>body{font-family:Microsoft YaHei,sans-serif;max-width:680px;margin:48px auto;line-height:class="n">1.8;padding:class="n">0 20px}" 52 "code{background:class=class="s">"c">#f2f2f2;padding:2px 6px;border-radius:4px}</style>" 53 "<h1>IMA 本地桥梁已启动</h1>" 54 "<p>请回到《课程AI助手 v4.class="n">4》的「📒 知识库问答」页签,连接模式保持 <code>本地代理</code>。</p>" 55 "<p>代理地址:<code>http://class="n">127.0.class="n">0.1:class="n">8766/api/ima</code></p>" 56 "<p>本服务只做浏览器与 ima.qq.com 之间的白名单转发,不保存凭据和查询内容。</p>" 57 ).encode("utf-class="n">8") 58 self.send_response(class="n">200) 59 cors_headers(self, "text/html; charset=utf-class="n">8") 60 self.end_headers() 61 self.wfile.write(text) 62 return 63 self._json(class="n">404, {"code": class="n">404, "msg": "not found", "hint": "GET /health 或 POST /api/ima/search_knowledge"}) 64 65 def do_POST(self): 66 path = urllib.parse.urlparse(self.path).path.strip("/") 67 prefix = "api/ima/" 68 if not path.startswith(prefix) or path[len(prefix):] not in ALLOWED_ENDPOINTS: 69 self._json(class="n">404, {"code": class="n">404, "msg": "endpoint not allowed", "allowed": sorted(ALLOWED_ENDPOINTS)}) 70 return 71 endpoint = path[len(prefix):] 72 client_id = (self.headers.get("ima-openapi-clientid") or "").strip() 73 api_key = (self.headers.get("ima-openapi-apikey") or "").strip() 74 if not client_id or not api_key: 75 self._json(class="n">401, {"code": class="n">401, "msg": "缺少 ima Client ID 或 API Key;请在知识库模块中输入或启用教师个人版内置凭据"}) 76 return 77 try: 78 length = int(self.headers.get("Content-Length") or "class="n">0") 79 payload = self.rfile.read(length) 80 if length > class="n">1024 * class="n">1024: 81 self._json(class="n">413, {"code": class="n">413, "msg": "请求体过大"}) 82 return 83 class=class="s">"c"># 校验 JSON,避免灰色请求透传 84 parsed = json.loads(payload.decode("utf-class="n">8") or b"{}".decode()) 85 body = json.dumps(parsed, ensure_ascii=False).encode("utf-class="n">8") 86 except Exception as exc: 87 self._json(class="n">400, {"code": class="n">400, "msg": "JSON 请求体无效", "detail": str(exc)}) 88 return 89 req = urllib.request.Request( 90 IMA_BASE + "/" + endpoint, 91 data=body, 92 method="POST", 93 headers={ 94 "Content-Type": "application/json; charset=utf-class="n">8", 95 "ima-openapi-clientid": client_id, 96 "ima-openapi-apikey": api_key, 97 "User-Agent": "LingxiIMAProxy/class="n">1.0", 98 }, 99 ) 100 try: 101 with urllib.request.urlopen(req, timeout=class="n">60) as res: 102 out = res.read() 103 status = res.status 104 except urllib.error.HTTPError as exc: 105 out = exc.read() 106 status = exc.code 107 except Exception as exc: 108 self._json(class="n">502, {"code": class="n">502, "msg": "无法连接 ima.qq.com", "detail": str(exc)}) 109 return 110 self.send_response(status) 111 cors_headers(self, "application/json; charset=utf-class="n">8") 112 self.end_headers() 113 self.wfile.write(out) 114 115 def _json(self, status, obj): 116 data = json.dumps(obj, ensure_ascii=False).encode("utf-class="n">8") 117 self.send_response(status) 118 cors_headers(self) 119 self.send_header("Content-Length", str(len(data))) 120 self.end_headers() 121 self.wfile.write(data) 122 123 124if __name__ == "__main__": 125 server = ThreadingHTTPServer((HOST, PORT), IMAProxyHandler) 126 print(f"IMA 本地桥梁已启动:http://{HOST}:{PORT}/api/ima") 127 print("保持本窗口打开即可;关闭窗口即停止服务。Ctrl+C 退出。") 128 try: 129 server.serve_forever() 130 except KeyboardInterrupt: 131 print("\nIMA 本地桥梁已停止")
3D 摄像头启动器(启动3D摄像头课堂.cmd)
适用对象:作者本人(或拥有完整工作目录的二次开发者)。
本文件是作者本机 v6.8 配套启动器。它会调用同一目录下
work\avatar-camera\local-server.cjs 起一个允许浏览器 getUserMedia 的本地服务。
公开访客直接通过 https://zgdgt.cn/course-ai-assistant-3d.html 体验即可——无需本地启动任何东西。
作者机使用步骤
- 把
课程AI助手(双课教学版-v6.8-3D摄像头交互版).html与同目录全部work/avatar-camera/保留在同一文件夹。 - 安装 Node.js(v18+)。
- 双击
启动3D摄像头课堂.cmd。窗口里出现的本机 URL 在 Edge/Chrome 中打开即可获得摄像头权限。 - 关闭 cmd 窗口即停止服务。
代码
@echo off setlocal cd /d "%~dp0" where node >nul 2>nul if errorlevel 1 ( echo Node.js is required. You can also open the v6.8 HTML directly in Edge. pause exit /b 1 ) node "work\avatar-camera\local-server.cjs" pause