返回 html-video
media-config.ts
根目录 / packages / cli / src / media-config.ts
1 /**
2 * Studio media-provider config — persists API credentials entered through the
3 * Settings UI to `.html-video/media-config.json` under the project root, so
4 * users don't have to set environment variables by hand.
5 *
6 * Credential precedence when resolving (config file wins over env, since the
7 * GUI is the explicit user choice):
8 * media-config.json → OD_MINIMAX_API_KEY / MINIMAX_API_KEY env
9 *
10 * Mirrors open-design's `.od/media-config.json` shape loosely; we only need
11 * MiniMax here. The file holds the raw key, so it lives in the gitignored
12 * `.html-video/` runtime dir, never the repo.
13 */
14
15 import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
16 import { join } from 'node:path';
17 import { resolveMinimaxCredentials, type MinimaxCredentials } from '@html-video/core';
18
19 interface MediaConfig {
20 minimax?: { apiKey?: string; baseUrl?: string };
21 }
22
23 export class MediaConfigStore {
24 private readonly path: string;
25 private readonly dir: string;
26
27 constructor(projectRoot: string) {
28 this.dir = join(projectRoot, '.html-video');
29 this.path = join(this.dir, 'media-config.json');
30 }
31
32 private read(): MediaConfig {
33 if (!existsSync(this.path)) return {};
34 try {
35 return JSON.parse(readFileSync(this.path, 'utf8')) as MediaConfig;
36 } catch {
37 return {};
38 }
39 }
40
41 private write(cfg: MediaConfig): void {
42 if (!existsSync(this.dir)) mkdirSync(this.dir, { recursive: true });
43 writeFileSync(this.path, JSON.stringify(cfg, null, 2), { mode: 0o600 });
44 }
45
46 /** What the Settings UI shows: whether a key is set + masked key + base URL.
47 * Never returns the raw key. Reports the source (config file vs env). */
48 getMinimaxStatus(): { configured: boolean; source: 'config' | 'env' | 'none'; maskedKey: string; baseUrl: string } {
49 const cfg = this.read().minimax;
50 if (cfg?.apiKey) {
51 return { configured: true, source: 'config', maskedKey: mask(cfg.apiKey), baseUrl: cfg.baseUrl ?? '' };
52 }
53 const env = resolveMinimaxCredentials();
54 if (env) {
55 return { configured: true, source: 'env', maskedKey: mask(env.apiKey), baseUrl: env.baseUrl };
56 }
57 return { configured: false, source: 'none', maskedKey: '', baseUrl: '' };
58 }
59
60 /** Persist a key (and optional base URL) entered in the UI. */
61 setMinimax(apiKey: string, baseUrl?: string): void {
62 const cfg = this.read();
63 cfg.minimax = { apiKey: apiKey.trim() };
64 const b = (baseUrl ?? '').trim();
65 if (b) cfg.minimax.baseUrl = b;
66 this.write(cfg);
67 }
68
69 /** Forget the stored MiniMax key (env fallback, if any, still applies). */
70 clearMinimax(): void {
71 const cfg = this.read();
72 delete cfg.minimax;
73 this.write(cfg);
74 }
75
76 /** Resolve usable credentials: config file first, then env. null if neither. */
77 resolveMinimax(): MinimaxCredentials | null {
78 const cfg = this.read().minimax;
79 if (cfg?.apiKey) {
80 // Default to the international endpoint when none is set. The old
81 // api.minimaxi.chat host is RETIRED server-side (issue #4); MiniMax now
82 // splits into api.minimax.io (international) and api.minimaxi.com (China),
83 // and keys are region-bound — so the Settings UI asks the user to pick.
84 const baseUrl = (cfg.baseUrl || '').trim().replace(/\/$/, '') || 'https://api.minimax.io/v1';
85 return { apiKey: cfg.apiKey, baseUrl };
86 }
87 return resolveMinimaxCredentials();
88 }
89 }
90
91 function mask(key: string): string {
92 if (key.length <= 8) return '••••';
93 return `${key.slice(0, 4)}••••${key.slice(-4)}`;
94 }
95
95 lines TYPESCRIPT