@openbrt/tvctl 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @openbrt/tvctl
2
+
3
+ 把文档、图片和文字推进**电视上的 ChatGPT kiosk**。
4
+
5
+ 电视上的 ChatGPT 网页版没有可用的文件入口——遥控器没有指针,输入区在 kiosk 里也被
6
+ 精简掉了,而 Live 语音模式本身就不接受附件。`tvctl` 补的就是这条缺失的投递通道。
7
+
8
+ ```bash
9
+ npm i -g @openbrt/tvctl
10
+
11
+ tvctl bind 192.168.8.179 --token 123456 # 配对码显示在电视上
12
+ tvctl attach photo.png --prompt "用简单英语讲讲这张图"
13
+ tvctl live start
14
+ ```
15
+
16
+ ## 传输
17
+
18
+ **局域网 HTTP 直连**,kiosk 自己起一个小 server,CLI 直接把字节 POST 过去。
19
+ 不经过任何云端、对象存储或消息队列;手机和电视必须在同一个局域网。
20
+
21
+ 这条通道**只在电视上的 kiosk 活着并且在前台时才通**。电视关机、休眠或 App 被杀之后
22
+ 推不进去,也不排队——`tvctl` 会直接报连不上,不会假装成功。
23
+
24
+ ## 命令
25
+
26
+ | 命令 | 作用 |
27
+ |---|---|
28
+ | `tvctl bind HOST[:PORT] --token CODE [--name NAME]` | 绑定一台电视,配对码来自电视屏幕 |
29
+ | `tvctl devices` / `tvctl use NAME` | 多台电视时列出/切换 |
30
+ | `tvctl inspect [--json]` | 电视当前状态和能力 |
31
+ | `tvctl attach FILE... [--prompt TEXT] [--no-send]` | 把文件推进对话 |
32
+ | `tvctl say TEXT [--no-send]` | 把文字推进对话 |
33
+ | `tvctl live start\|stop` | 开始/结束 Live 语音 |
34
+ | `tvctl doctor [--json]` | 连通性排查 |
35
+
36
+ 通用参数 `--host --port --token --device --json`;
37
+ 环境变量 `TVCTL_HOST TVCTL_PORT TVCTL_TOKEN TVCTL_DEVICE TVCTL_CONFIG_DIR`。
38
+
39
+ 配置存在 `~/.config/tvctl/config.json`(0600,里面有配对码)。
40
+
41
+ ## 给 AI Agent 用
42
+
43
+ 包里带 `agent-plugin.json`(`openbrt.agent_plugin.v1`),声明了能力、边界和
44
+ 自然语言任务映射。WorkBuddy / Claude Code / Codex / OpenClaw 这类 agent 读它,
45
+ 然后直接 shell 调 `tvctl`。
46
+
47
+ ```js
48
+ import manifest from '@openbrt/tvctl/manifest' with { type: 'json' };
49
+ ```
50
+
51
+ **一个必须知道的产品前提**:ChatGPT 的 Live 语音模式不接受附件。正确形态是
52
+ 「先 `attach` 把图/文档发进普通对话 → 再 `live start` 让人就着这个上下文说话」,
53
+ 而不是「Live 进行中塞一张图」。
54
+
55
+ ## 协议
56
+
57
+ 设备端要实现这四个路由(`Authorization: Bearer <配对码>`):
58
+
59
+ ```
60
+ GET /v1/health -> {ok, device{name,model}, app{package,version}, capabilities[], state{view,live,voice}}
61
+ GET /v1/state -> {ok, state{...}}
62
+ POST /v1/attach multipart/form-data: file(可多个), prompt?, send=1|0
63
+ POST /v1/say application/json: {text, send}
64
+ POST /v1/live application/json: {action:"start"|"stop"}
65
+ ```
66
+
67
+ 除 health/state 外都返回回执 `{ok, status: "applied"|"rejected"|"failed", reason?}`。
68
+ 单文件上限 32 MB——电视那头内存很紧,超了先压缩,不要分片硬塞。
69
+
70
+ 参考实现:[chatgpt-tv-box](https://github.com/) 里的 `com.local.gvkiosk`(GeckoView kiosk)。
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,95 @@
1
+ {
2
+ "schema": "openbrt.agent_plugin.v1",
3
+ "id": "tvctl",
4
+ "name": "tvctl",
5
+ "package": "@openbrt/tvctl",
6
+ "bin": "tvctl",
7
+ "description": "Push documents, images and text from a phone or AI agent into a ChatGPT kiosk running on a TV box.",
8
+ "transport": "lan/http",
9
+ "why_this_exists": "电视上的 ChatGPT 网页版没有可用的文件入口——遥控器没有指针,输入区在 kiosk 里也被精简掉了,而 Live 语音模式本身就不接受附件。tvctl 补的就是这条缺失的投递通道:手机上的 agent 直接把字节推给电视,电视自己把它喂进对话。",
10
+ "hardware_abstraction": {
11
+ "agent_facing_type": "chat_kiosk_display",
12
+ "rule": "agent 只通过 tvctl inspect 返回的 capabilities 和回执理解设备;不要推断电视品牌、型号、Android 版本或厂商系统,也不要去搜索这些资料。"
13
+ },
14
+ "user_contract": "用户只需要给出电视的地址和配对码,以及「想让电视上的 ChatGPT 看什么」。agent 负责选文件、写提示词、决定发不发送,并根据回执报告结果。",
15
+ "transport_policy": {
16
+ "role": "data_plane_and_control",
17
+ "note": "字节直接走局域网 HTTP 推给电视,不经过任何云端、对象存储或消息队列。手机和电视必须在同一个局域网里。",
18
+ "agent_must_not": [
19
+ "不要把用户的文件上传到第三方图床或对象存储再让电视去拉——这条通道的全部意义就是不出局域网",
20
+ "不要写轮询循环反复调 tvctl inspect 去等状态;需要等结果就读 attach 的回执",
21
+ "不要在未获得用户明确同意时推送用户没有主动交给你的文件",
22
+ "不要假设电视关机、休眠或 kiosk 被杀之后还能收到推送——这条通道只在 App 活着时通"
23
+ ]
24
+ },
25
+ "preconditions": [
26
+ "电视上的 kiosk(com.local.gvkiosk)正在前台运行",
27
+ "手机/电脑与电视在同一局域网",
28
+ "已经跑过一次 tvctl bind,配对码来自电视屏幕"
29
+ ],
30
+ "device_classes": [
31
+ "chat_kiosk_display",
32
+ "tv_box",
33
+ "projector"
34
+ ],
35
+ "agents": [
36
+ "WorkBuddy",
37
+ "Claude Code",
38
+ "Codex",
39
+ "OpenClaw",
40
+ "Hermes",
41
+ "Gemini CLI",
42
+ "OpenCode"
43
+ ],
44
+ "capabilities": [
45
+ "chat.attach",
46
+ "chat.say",
47
+ "chat.send",
48
+ "live.start",
49
+ "live.stop",
50
+ "device.inspect",
51
+ "device.state.read"
52
+ ],
53
+ "limits": {
54
+ "max_file_bytes": 33554432,
55
+ "note": "电视那头内存很紧(实测常驻只剩 400MB 量级),单个文件上限 32MB。超了先压缩或裁剪,不要分片硬塞。"
56
+ },
57
+ "product_constraints": [
58
+ "ChatGPT 的 Live 语音模式本身不接受附件。正确形态是:先用 chat.attach 把图/文档发进普通对话,再用 live.start 让用户就着这个上下文说话。不要承诺「Live 进行中塞一张图」。",
59
+ "kiosk 的界面被精简过,用户在电视上看到的是滚动对话区 + 一个语音按钮。推进去的内容会出现在对话区里。"
60
+ ],
61
+ "natural_language_tasks": [
62
+ {
63
+ "intent": "把我手机里这张图发到电视上的 ChatGPT,让它讲讲里面的英文",
64
+ "primary_command": "tvctl attach IMAGE.png --prompt \"用简单英语讲讲这张图,然后问我一个问题\" --json",
65
+ "agent_strategy": "先运行 tvctl inspect --json 确认电视在线、kiosk 在前台。把文件路径和提示词一起交给 attach,默认会直接发送。回执里 status=applied 才算成功。如果用户接下来想开口说话,再运行 tvctl live start。",
66
+ "if_unbound": "只向用户要电视 IP 和电视屏幕上显示的配对码,然后运行 tvctl bind HOST --token CODE;不要要求用户理解端口、token 存在哪或局域网细节。",
67
+ "if_offline": "不要重试轮询。告诉用户这条通道只在电视上的 kiosk 活着时才通,请先把电视切到那个应用。"
68
+ },
69
+ {
70
+ "intent": "把这份 PDF 推到电视上,先别发,我自己在电视上看着再说",
71
+ "primary_command": "tvctl attach DOC.pdf --no-send --json",
72
+ "agent_strategy": "用 --no-send,文件只会挂在输入框上,不会发出去。回执 status=applied 表示附件已经挂上。"
73
+ },
74
+ {
75
+ "intent": "让电视上的 ChatGPT 开始跟我语音对话",
76
+ "primary_command": "tvctl live start --json",
77
+ "agent_strategy": "先 tvctl inspect --json 看 state.live。已经在 live 里就不要重复调用。结束用 tvctl live stop。"
78
+ },
79
+ {
80
+ "intent": "推不过去,帮我看看哪一环断了",
81
+ "primary_command": "tvctl doctor --json",
82
+ "agent_strategy": "doctor 会逐步报告配置、连通性和 kiosk 健康状态。最常见的两个原因是「电视上 kiosk 不在前台」和「手机不在家里的网」,先按这两条问用户,不要一上来怀疑配对码。"
83
+ }
84
+ ],
85
+ "commands": [
86
+ { "name": "bind", "usage": "tvctl bind HOST[:PORT] --token CODE [--name NAME]" },
87
+ { "name": "devices", "usage": "tvctl devices" },
88
+ { "name": "use", "usage": "tvctl use NAME" },
89
+ { "name": "inspect", "usage": "tvctl inspect --json" },
90
+ { "name": "attach", "usage": "tvctl attach FILE... [--prompt TEXT] [--no-send] --json" },
91
+ { "name": "say", "usage": "tvctl say TEXT [--no-send] --json" },
92
+ { "name": "live", "usage": "tvctl live start|stop --json" },
93
+ { "name": "doctor", "usage": "tvctl doctor --json" }
94
+ ]
95
+ }
package/bin/tvctl.mjs ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path';
3
+ import {
4
+ DEFAULT_PORT, loadConfig, saveConfig, resolveTarget, parseHost,
5
+ health, state, attach, say, live, TvctlError, baseUrl,
6
+ } from '../lib/index.mjs';
7
+
8
+ const USAGE = `tvctl —— 把文档、图片和文字推进电视上的 ChatGPT kiosk
9
+
10
+ tvctl bind <HOST[:PORT]> --token CODE [--name NAME] 绑定一台电视(配对码显示在电视上)
11
+ tvctl devices 列出已绑定的电视
12
+ tvctl use <NAME> 切换默认电视
13
+ tvctl inspect [--json] 看电视当前状态和能力
14
+ tvctl attach <FILE...> [--prompt TEXT] [--no-send] 把文件推进对话
15
+ tvctl say <TEXT> [--no-send] 把文字推进对话
16
+ tvctl live start|stop 开始/结束 Live 语音
17
+ tvctl doctor [--json] 连通性排查
18
+
19
+ 通用参数:--host HOST --port PORT --token TOKEN --device NAME --json
20
+ 环境变量:TVCTL_HOST TVCTL_PORT TVCTL_TOKEN TVCTL_DEVICE TVCTL_CONFIG_DIR
21
+ `;
22
+
23
+ function parseArgs(argv) {
24
+ const opts = {};
25
+ const rest = [];
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === '--') { rest.push(...argv.slice(i + 1)); break; }
29
+ if (a === '--json') opts.json = true;
30
+ else if (a === '--send') opts.send = true;
31
+ else if (a === '--no-send') opts.send = false;
32
+ else if (a === '--default') opts.default = true;
33
+ else if (a.startsWith('--')) {
34
+ const eq = a.indexOf('=');
35
+ if (eq > 0) opts[a.slice(2, eq)] = a.slice(eq + 1);
36
+ else opts[a.slice(2)] = argv[++i];
37
+ } else rest.push(a);
38
+ }
39
+ return { opts, rest };
40
+ }
41
+
42
+ function out(opts, obj, human) {
43
+ if (opts.json) console.log(JSON.stringify(obj, null, 2));
44
+ else console.log(human);
45
+ }
46
+
47
+ function fail(opts, e) {
48
+ const msg = e instanceof TvctlError ? e.message : (e?.stack || String(e));
49
+ if (opts.json) console.log(JSON.stringify({ ok: false, error: String(e?.message || e) }, null, 2));
50
+ else console.error('✗ ' + msg);
51
+ process.exit(1);
52
+ }
53
+
54
+ async function main() {
55
+ const { opts, rest } = parseArgs(process.argv.slice(2));
56
+ const cmd = rest.shift();
57
+ if (!cmd || cmd === 'help' || opts.help) { console.log(USAGE); return; }
58
+
59
+ const cfg = await loadConfig();
60
+
61
+ if (cmd === 'bind') {
62
+ const where = rest[0];
63
+ if (!where) throw new TvctlError('用法:tvctl bind <HOST[:PORT]> --token CODE');
64
+ const { host, port } = parseHost(where);
65
+ const token = opts.token || process.env.TVCTL_TOKEN;
66
+ if (!token) {
67
+ throw new TvctlError('缺 --token。配对码显示在电视上(kiosk 的配对界面),六位。');
68
+ }
69
+ const name = opts.name || host;
70
+ const target = { name, host, port, token };
71
+ // 先连一下再落盘,别把一个连不通的配置存下来
72
+ const h = await health(target);
73
+ cfg.devices[name] = { host, port, token };
74
+ if (opts.default || !cfg.default) cfg.default = name;
75
+ const p = await saveConfig(cfg);
76
+ out(opts, { ok: true, name, host, port, health: h, config: p },
77
+ `✓ 已绑定 ${name} (${host}:${port})\n ${h?.device?.name || ''} ${h?.app?.package || ''} ${h?.app?.version || ''}\n 配置写入 ${p}`);
78
+ return;
79
+ }
80
+
81
+ if (cmd === 'devices') {
82
+ const names = Object.keys(cfg.devices);
83
+ out(opts, { ok: true, default: cfg.default || null, devices: cfg.devices },
84
+ names.length
85
+ ? names.map((n) => `${n === cfg.default ? '*' : ' '} ${n}\t${cfg.devices[n].host}:${cfg.devices[n].port || DEFAULT_PORT}`).join('\n')
86
+ : '(还没绑定任何电视,先跑 tvctl bind)');
87
+ return;
88
+ }
89
+
90
+ if (cmd === 'use') {
91
+ const name = rest[0];
92
+ if (!cfg.devices[name]) throw new TvctlError(`没有叫 ${name} 的电视。tvctl devices 看看有哪些。`);
93
+ cfg.default = name;
94
+ await saveConfig(cfg);
95
+ out(opts, { ok: true, default: name }, `✓ 默认电视切到 ${name}`);
96
+ return;
97
+ }
98
+
99
+ const target = resolveTarget(cfg, opts);
100
+
101
+ if (cmd === 'inspect') {
102
+ const h = await health(target);
103
+ out(opts, h, [
104
+ `电视 ${h?.device?.name || '?'} ${h?.device?.model || ''}`,
105
+ `应用 ${h?.app?.package || '?'} ${h?.app?.version || ''}`,
106
+ `当前 view=${h?.state?.view ?? '?'} live=${h?.state?.live ?? '?'} voice=${h?.state?.voice ?? '?'}`,
107
+ `能力 ${(h?.capabilities || []).join(', ')}`,
108
+ ].join('\n'));
109
+ return;
110
+ }
111
+
112
+ if (cmd === 'state') {
113
+ out(opts, await state(target), JSON.stringify(await state(target)));
114
+ return;
115
+ }
116
+
117
+ if (cmd === 'attach') {
118
+ if (!rest.length) throw new TvctlError('用法:tvctl attach <FILE...> [--prompt TEXT]');
119
+ const send = opts.send !== false;
120
+ const r = await attach(target, rest, { prompt: opts.prompt || '', send });
121
+ out(opts, r,
122
+ `✓ ${r.status || 'applied'} ${r.files.map((f) => `${f.name} (${(f.bytes / 1024).toFixed(0)} KB)`).join(', ')}` +
123
+ (opts.prompt ? `\n 提示词:${opts.prompt}` : '') +
124
+ (send ? '\n 已发送' : '\n 已挂在输入框,没发送'));
125
+ return;
126
+ }
127
+
128
+ if (cmd === 'say') {
129
+ const text = rest.join(' ');
130
+ const r = await say(target, text, { send: opts.send !== false });
131
+ out(opts, r, `✓ ${r.status || 'applied'} ${text}`);
132
+ return;
133
+ }
134
+
135
+ if (cmd === 'live') {
136
+ const action = rest[0];
137
+ const r = await live(target, action);
138
+ out(opts, r, `✓ ${r.status || 'applied'} live ${action}`);
139
+ return;
140
+ }
141
+
142
+ if (cmd === 'doctor') {
143
+ const report = { ok: false, target: { host: target.host, port: target.port, device: target.name }, steps: [] };
144
+ if (!target.host) {
145
+ report.steps.push({ step: 'config', ok: false, detail: '还没绑定电视,先跑 tvctl bind' });
146
+ out(opts, report, '✗ 还没绑定电视,先跑 tvctl bind <电视IP> --token <配对码>');
147
+ process.exit(1);
148
+ }
149
+ report.steps.push({ step: 'config', ok: true, detail: baseUrl(target) });
150
+ try {
151
+ const h = await health(target);
152
+ report.steps.push({ step: 'health', ok: true, detail: h });
153
+ report.ok = true;
154
+ out(opts, report, `✓ ${baseUrl(target)} 通\n ${h?.app?.package || ''} ${h?.app?.version || ''} view=${h?.state?.view}`);
155
+ } catch (e) {
156
+ report.steps.push({ step: 'health', ok: false, detail: String(e.message || e) });
157
+ out(opts, report, `✗ ${baseUrl(target)} 不通\n ${e.message}`);
158
+ process.exit(1);
159
+ }
160
+ return;
161
+ }
162
+
163
+ throw new TvctlError(`不认识的命令:${cmd}\n\n${USAGE}`);
164
+ }
165
+
166
+ const { opts } = parseArgs(process.argv.slice(2));
167
+ main().catch((e) => fail(opts, e));
package/lib/index.mjs ADDED
@@ -0,0 +1,190 @@
1
+ // @openbrt/tvctl —— 把文档、图片和文字推进电视上的 ChatGPT kiosk。
2
+ //
3
+ // 传输就是局域网 HTTP:kiosk 自己起一个小 server,CLI 直接把字节 POST 过去。
4
+ // 不走对象存储、不走 MQTT、不需要公网——手机和电视本来就在同一个家里。
5
+
6
+ import { createHash } from 'node:crypto';
7
+ import { readFile, mkdir, writeFile, chmod } from 'node:fs/promises';
8
+ import { homedir } from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ export const DEFAULT_PORT = 8770;
12
+ export const API_PREFIX = '/v1';
13
+
14
+ /** 单个文件的大小上限。电视那头只有 2~4GB 内存,别把大文件塞过去。 */
15
+ export const MAX_FILE_BYTES = 32 * 1024 * 1024;
16
+
17
+ export function configPath() {
18
+ const base = process.env.TVCTL_CONFIG_DIR ||
19
+ path.join(process.env.XDG_CONFIG_HOME || path.join(homedir(), '.config'), 'tvctl');
20
+ return path.join(base, 'config.json');
21
+ }
22
+
23
+ export async function loadConfig() {
24
+ try {
25
+ const raw = await readFile(configPath(), 'utf8');
26
+ const cfg = JSON.parse(raw);
27
+ if (!cfg || typeof cfg !== 'object') return { devices: {} };
28
+ if (!cfg.devices || typeof cfg.devices !== 'object') cfg.devices = {};
29
+ return cfg;
30
+ } catch {
31
+ return { devices: {} };
32
+ }
33
+ }
34
+
35
+ export async function saveConfig(cfg) {
36
+ const p = configPath();
37
+ await mkdir(path.dirname(p), { recursive: true });
38
+ await writeFile(p, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
39
+ // 里面有 token,别让同机其他用户读到
40
+ await chmod(p, 0o600);
41
+ return p;
42
+ }
43
+
44
+ /**
45
+ * 解析出「这次要操作哪台设备」。
46
+ * 优先级:命令行 --host/--token > 环境变量 > 配置文件里 --device 指定的 > 默认设备。
47
+ */
48
+ export function resolveTarget(cfg, opts = {}) {
49
+ const name = opts.device || process.env.TVCTL_DEVICE || cfg.default ||
50
+ Object.keys(cfg.devices || {})[0];
51
+ const saved = (name && cfg.devices && cfg.devices[name]) || {};
52
+ const host = opts.host || process.env.TVCTL_HOST || saved.host;
53
+ const port = Number(opts.port || process.env.TVCTL_PORT || saved.port || DEFAULT_PORT);
54
+ const token = opts.token || process.env.TVCTL_TOKEN || saved.token || '';
55
+ return { name: name || null, host: host || null, port, token };
56
+ }
57
+
58
+ export function parseHost(input) {
59
+ const s = String(input || '').trim().replace(/^https?:\/\//i, '').replace(/\/+$/, '');
60
+ if (!s) throw new Error('host 不能为空');
61
+ const m = /^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/.exec(s);
62
+ if (!m) throw new Error(`认不出这个地址:${input}`);
63
+ return { host: m[1], port: m[2] ? Number(m[2]) : DEFAULT_PORT };
64
+ }
65
+
66
+ export function baseUrl(target) {
67
+ const host = target.host.includes(':') && !target.host.startsWith('[')
68
+ ? `[${target.host}]`
69
+ : target.host;
70
+ return `http://${host}:${target.port}${API_PREFIX}`;
71
+ }
72
+
73
+ class TvctlError extends Error {
74
+ constructor(message, extra = {}) {
75
+ super(message);
76
+ this.name = 'TvctlError';
77
+ Object.assign(this, extra);
78
+ }
79
+ }
80
+ export { TvctlError };
81
+
82
+ async function request(target, route, { method = 'GET', body, headers = {}, timeoutMs = 20000 } = {}) {
83
+ if (!target.host) {
84
+ throw new TvctlError('还没绑定电视。先运行:tvctl bind <电视IP> --token <电视上显示的配对码>');
85
+ }
86
+ const url = baseUrl(target) + route;
87
+ const h = { ...headers };
88
+ if (target.token) h.Authorization = `Bearer ${target.token}`;
89
+
90
+ let res;
91
+ try {
92
+ res = await fetch(url, { method, body, headers: h, signal: AbortSignal.timeout(timeoutMs) });
93
+ } catch (e) {
94
+ const hint = e?.name === 'TimeoutError'
95
+ ? '超时。电视上的 kiosk 可能没在前台运行——这条通道只在 App 活着的时候通。'
96
+ : '连不上。确认手机/电脑和电视在同一个局域网,且 kiosk 正在运行。';
97
+ throw new TvctlError(`${url} ${hint}`, { cause: String(e?.message || e) });
98
+ }
99
+
100
+ const text = await res.text();
101
+ let data = null;
102
+ try { data = text ? JSON.parse(text) : null; } catch { /* 非 JSON 原样带回去 */ }
103
+
104
+ if (!res.ok) {
105
+ const reason = data?.reason || data?.error || text.slice(0, 200) || `HTTP ${res.status}`;
106
+ throw new TvctlError(
107
+ res.status === 401 ? `配对码不对或已失效:${reason}` : `电视拒绝了这条请求:${reason}`,
108
+ { status: res.status, body: data },
109
+ );
110
+ }
111
+ return data ?? { ok: true, raw: text };
112
+ }
113
+
114
+ export function sha256(buf) {
115
+ return createHash('sha256').update(buf).digest('hex');
116
+ }
117
+
118
+ const MIME = new Map(Object.entries({
119
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
120
+ '.webp': 'image/webp', '.heic': 'image/heic', '.bmp': 'image/bmp',
121
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
122
+ '.csv': 'text/csv', '.json': 'application/json',
123
+ '.doc': 'application/msword',
124
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
125
+ '.xls': 'application/vnd.ms-excel',
126
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
127
+ '.ppt': 'application/vnd.ms-powerpoint',
128
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
129
+ }));
130
+
131
+ export function guessMime(file) {
132
+ return MIME.get(path.extname(file).toLowerCase()) || 'application/octet-stream';
133
+ }
134
+
135
+ export async function health(target) {
136
+ return request(target, '/health', { timeoutMs: 8000 });
137
+ }
138
+
139
+ export async function state(target) {
140
+ return request(target, '/state', { timeoutMs: 8000 });
141
+ }
142
+
143
+ /**
144
+ * 把文件推进电视上的 ChatGPT 对话。
145
+ * send=true 时电视会连带把这条消息发出去;否则只挂上附件、等人在电视上继续。
146
+ */
147
+ export async function attach(target, files, { prompt = '', send = true, timeoutMs = 120000 } = {}) {
148
+ if (!files || !files.length) throw new TvctlError('至少要给一个文件');
149
+ const form = new FormData();
150
+ const meta = [];
151
+ for (const f of files) {
152
+ const buf = await readFile(f);
153
+ if (buf.byteLength > MAX_FILE_BYTES) {
154
+ throw new TvctlError(
155
+ `${path.basename(f)} 有 ${(buf.byteLength / 1048576).toFixed(1)} MB,` +
156
+ `超过上限 ${MAX_FILE_BYTES / 1048576} MB。电视那头内存很紧,先压缩或裁剪。`,
157
+ );
158
+ }
159
+ const type = guessMime(f);
160
+ form.append('file', new Blob([buf], { type }), path.basename(f));
161
+ meta.push({ name: path.basename(f), bytes: buf.byteLength, sha256: sha256(buf), type });
162
+ }
163
+ if (prompt) form.append('prompt', prompt);
164
+ form.append('send', send ? '1' : '0');
165
+
166
+ const res = await request(target, '/attach', { method: 'POST', body: form, timeoutMs });
167
+ return { ...res, files: meta };
168
+ }
169
+
170
+ /** 往对话里打一段文字。默认直接发出去。 */
171
+ export async function say(target, text, { send = true, timeoutMs = 30000 } = {}) {
172
+ if (!text || !String(text).trim()) throw new TvctlError('文字不能为空');
173
+ return request(target, '/say', {
174
+ method: 'POST',
175
+ headers: { 'content-type': 'application/json' },
176
+ body: JSON.stringify({ text: String(text), send: !!send }),
177
+ timeoutMs,
178
+ });
179
+ }
180
+
181
+ /** 开始/结束电视上的 Live 语音。 */
182
+ export async function live(target, action, { timeoutMs = 30000 } = {}) {
183
+ if (!['start', 'stop'].includes(action)) throw new TvctlError('action 只能是 start 或 stop');
184
+ return request(target, '/live', {
185
+ method: 'POST',
186
+ headers: { 'content-type': 'application/json' },
187
+ body: JSON.stringify({ action }),
188
+ timeoutMs,
189
+ });
190
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@openbrt/tvctl",
3
+ "version": "0.1.0",
4
+ "description": "Push documents, images and text from a phone or AI agent into a ChatGPT kiosk running on a TV box.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "chatgpt",
9
+ "tv",
10
+ "android-tv",
11
+ "kiosk",
12
+ "geckoview",
13
+ "agent",
14
+ "openclaw",
15
+ "workbuddy",
16
+ "claude-code",
17
+ "codex"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "files": [
26
+ "agent-plugin.json",
27
+ "bin",
28
+ "lib",
29
+ "README.md"
30
+ ],
31
+ "bin": {
32
+ "tvctl": "bin/tvctl.mjs"
33
+ },
34
+ "exports": {
35
+ ".": "./lib/index.mjs",
36
+ "./manifest": "./agent-plugin.json",
37
+ "./package.json": "./package.json"
38
+ },
39
+ "scripts": {
40
+ "check": "node --check lib/index.mjs && node --check bin/tvctl.mjs && node --test test/*.test.mjs"
41
+ }
42
+ }