feat(runtime): 即梦本机运行时 - 接入安全生成闭环与桌面本机渠道
Refs #225 Co-authored-by: zkhyww <127009345+zkhyww@users.noreply.github.com>
This commit is contained in:
1 parent
f549767cda
commit
914717cae6
179 files changed
+49746
-1897
No files matched your search
@@ -0,0 +1,298 @@
|
||||
import type { LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
|
||||
|
||||
export type DreaminaCliState = "missing" | "installed" | "login_pending" | "authenticated" | "error";
|
||||
export type DreaminaLifecycleStatusCode = "dreamina_missing" | "dreamina_version_failed" | "dreamina_login_required" | "dreamina_login_pending";
|
||||
|
||||
export type DreaminaCliStatus = {
|
||||
provider: "dreamina-cli";
|
||||
state: DreaminaCliState;
|
||||
installed: boolean;
|
||||
authenticated: boolean;
|
||||
version?: string;
|
||||
totalCredit?: number;
|
||||
accountBinding?: string;
|
||||
sessionEpoch?: number;
|
||||
creditObservedAt?: string;
|
||||
code?: DreaminaLifecycleStatusCode;
|
||||
message: string;
|
||||
verificationUri?: string;
|
||||
userCode?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
export type DreaminaRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export class DreaminaAgentError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DreaminaAgentError";
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
export const DREAMINA_REQUEST_TIMEOUTS = { status: 30_000, login: 35_000, logout: 20_000 } as const;
|
||||
export type DreaminaLifecycleAction = keyof typeof DREAMINA_REQUEST_TIMEOUTS;
|
||||
|
||||
export function dreaminaRequestTimeout(action: DreaminaLifecycleAction, override?: number) {
|
||||
return override ?? DREAMINA_REQUEST_TIMEOUTS[action];
|
||||
}
|
||||
const LIFECYCLE_CODES = new Set<DreaminaLifecycleStatusCode>(["dreamina_missing", "dreamina_version_failed", "dreamina_login_required", "dreamina_login_pending"]);
|
||||
const STABLE_ERROR_MESSAGES: Record<string, string> = {
|
||||
dreamina_cancelled: "Dreamina 操作已取消",
|
||||
dreamina_cleanup_failed: "Dreamina 进程清理失败,请检查本机进程状态",
|
||||
dreamina_command_invalid: "Dreamina CLI 命令无效",
|
||||
dreamina_command_timeout: "Dreamina 操作超时,相关进程已清理",
|
||||
dreamina_environment_invalid: "Dreamina 环境变量无效",
|
||||
dreamina_idempotency_conflict: "Dreamina 操作标识冲突",
|
||||
dreamina_internal_error: "Dreamina CLI 请求失败",
|
||||
dreamina_login_failed: "无法启动 Dreamina OAuth 登录",
|
||||
dreamina_login_pending: "请先完成 Dreamina 官方页面授权",
|
||||
dreamina_login_required: "Dreamina CLI 需要登录",
|
||||
dreamina_login_response_invalid: "Dreamina 登录响应无法识别,请升级 CLI",
|
||||
dreamina_logout_failed: "Dreamina 退出失败,请重试",
|
||||
dreamina_missing: "未检测到 Dreamina CLI",
|
||||
dreamina_output_too_large: "Dreamina 返回内容超过安全上限",
|
||||
dreamina_query_failed: "Dreamina 查询失败",
|
||||
dreamina_query_response_invalid: "Dreamina 查询响应无效",
|
||||
dreamina_reference_budget_exceeded: "Dreamina 参考文件超过安全上限",
|
||||
dreamina_reference_cleanup_failed: "Dreamina 参考文件清理失败",
|
||||
dreamina_reference_invalid: "Dreamina 参考文件无效",
|
||||
dreamina_request_invalid: "Dreamina 请求参数无效",
|
||||
dreamina_spawn_failed: "无法启动 Dreamina CLI",
|
||||
dreamina_state_busy: "Dreamina 正在处理另一项操作",
|
||||
dreamina_state_fenced: "Dreamina 操作已失效",
|
||||
dreamina_state_invalid: "Dreamina 本机状态无效",
|
||||
dreamina_submission_unknown: "Dreamina 提交结果未知,请先检查状态",
|
||||
dreamina_version_failed: "Dreamina CLI 版本检测失败",
|
||||
};
|
||||
|
||||
export function getDreaminaStatus(client: LocalRuntimeTransport, options: DreaminaRequestOptions = {}) {
|
||||
return requestDreamina(client, "GET", "/dreamina/status", {
|
||||
...options,
|
||||
timeoutMs: dreaminaRequestTimeout("status", options.timeoutMs),
|
||||
});
|
||||
}
|
||||
|
||||
export function loginDreamina(client: LocalRuntimeTransport, options: DreaminaRequestOptions = {}) {
|
||||
return requestDreamina(client, "POST", "/dreamina/login", {
|
||||
...options,
|
||||
timeoutMs: dreaminaRequestTimeout("login", options.timeoutMs),
|
||||
});
|
||||
}
|
||||
|
||||
export function logoutDreamina(client: LocalRuntimeTransport, options: DreaminaRequestOptions = {}) {
|
||||
return requestDreamina(client, "POST", "/dreamina/logout", {
|
||||
...options,
|
||||
timeoutMs: dreaminaRequestTimeout("logout", options.timeoutMs),
|
||||
});
|
||||
}
|
||||
|
||||
async function requestDreamina(client: LocalRuntimeTransport, method: "GET" | "POST", route: "/dreamina/status" | "/dreamina/login" | "/dreamina/logout", options: DreaminaRequestOptions) {
|
||||
if (options.signal?.aborted) throw publicError("dreamina_cancelled", 499);
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const abort = () => controller.abort();
|
||||
options.signal?.addEventListener("abort", abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
},
|
||||
Math.max(1, options.timeoutMs ?? DREAMINA_REQUEST_TIMEOUTS.status),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await client.request(route, {
|
||||
method,
|
||||
...(method === "POST"
|
||||
? {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{}",
|
||||
}
|
||||
: {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.redirected || response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
|
||||
throw publicError("dreamina_response_invalid", 502);
|
||||
}
|
||||
const body = await readBoundedJson(response);
|
||||
if (!response.ok || !isRecord(body) || body.ok !== true) {
|
||||
const code = isRecord(body) && typeof body.code === "string" && body.code in STABLE_ERROR_MESSAGES ? body.code : "dreamina_internal_error";
|
||||
throw publicError(code, response.status || 500);
|
||||
}
|
||||
return parseStatus(body.status, options.now?.() ?? Date.now());
|
||||
} catch (error) {
|
||||
if (error instanceof DreaminaAgentError) throw error;
|
||||
if (controller.signal.aborted) {
|
||||
throw publicError(timedOut ? "dreamina_timeout" : "dreamina_cancelled", timedOut ? 504 : 499);
|
||||
}
|
||||
if (error instanceof LocalRuntimeClientError && error.code === "session_required") {
|
||||
throw publicError("dreamina_runtime_required", 401);
|
||||
}
|
||||
throw publicError("dreamina_runtime_unreachable", 503);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
|
||||
throw publicError("dreamina_response_invalid", 502);
|
||||
}
|
||||
if (!response.body) throw publicError("dreamina_response_invalid", 502);
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const item = await reader.read();
|
||||
if (item.done) break;
|
||||
total += item.value.byteLength;
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw publicError("dreamina_response_invalid", 502);
|
||||
}
|
||||
chunks.push(item.value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
||||
} catch {
|
||||
throw publicError("dreamina_response_invalid", 502);
|
||||
}
|
||||
}
|
||||
|
||||
function parseStatus(value: unknown, now: number): DreaminaCliStatus {
|
||||
if (!isRecord(value)) throw publicError("dreamina_response_invalid", 502);
|
||||
const allowed = new Set(["provider", "state", "installed", "authenticated", "version", "code", "message"]);
|
||||
if (value.state === "authenticated") {
|
||||
allowed.add("totalCredit");
|
||||
allowed.add("accountBinding");
|
||||
allowed.add("sessionEpoch");
|
||||
allowed.add("creditObservedAt");
|
||||
}
|
||||
if (value.state === "login_pending") {
|
||||
allowed.add("verificationUri");
|
||||
allowed.add("userCode");
|
||||
allowed.add("expiresAt");
|
||||
}
|
||||
if (
|
||||
Object.keys(value).some((key) => !allowed.has(key)) ||
|
||||
value.provider !== "dreamina-cli" ||
|
||||
!isDreaminaState(value.state) ||
|
||||
typeof value.installed !== "boolean" ||
|
||||
typeof value.authenticated !== "boolean" ||
|
||||
typeof value.message !== "string" ||
|
||||
value.message.length < 1 ||
|
||||
value.message.length > 500 ||
|
||||
(value.version !== undefined && (typeof value.version !== "string" || !/^[A-Za-z0-9._+:-]{1,120}$/.test(value.version))) ||
|
||||
(value.totalCredit !== undefined && (value.state !== "authenticated" || !Number.isInteger(value.totalCredit) || (value.totalCredit as number) < 0 || (value.totalCredit as number) > 1_000_000_000)) ||
|
||||
(value.accountBinding === undefined) !== (value.sessionEpoch === undefined) ||
|
||||
(value.accountBinding !== undefined && (value.state !== "authenticated" || typeof value.accountBinding !== "string" || !/^[A-Za-z0-9._:-]{8,160}$/.test(value.accountBinding))) ||
|
||||
(value.sessionEpoch !== undefined && (value.state !== "authenticated" || !Number.isSafeInteger(value.sessionEpoch) || (value.sessionEpoch as number) < 0)) ||
|
||||
(value.creditObservedAt !== undefined && (value.totalCredit === undefined || typeof value.creditObservedAt !== "string" || !Number.isFinite(Date.parse(value.creditObservedAt)))) ||
|
||||
(value.code !== undefined && (typeof value.code !== "string" || !LIFECYCLE_CODES.has(value.code as DreaminaLifecycleStatusCode)))
|
||||
) {
|
||||
throw publicError("dreamina_response_invalid", 502);
|
||||
}
|
||||
const code = value.code as DreaminaLifecycleStatusCode | undefined;
|
||||
if (!consistentStatus(value.state, value.installed, value.authenticated, code)) {
|
||||
throw publicError("dreamina_response_invalid", 502);
|
||||
}
|
||||
|
||||
let verification: Pick<DreaminaCliStatus, "verificationUri" | "userCode" | "expiresAt"> = {};
|
||||
if (value.state === "login_pending") {
|
||||
if (!isOfficialVerificationUri(value.verificationUri) || typeof value.userCode !== "string" || !/^[A-Za-z0-9-]{4,32}$/.test(value.userCode) || typeof value.expiresAt !== "string") {
|
||||
throw publicError("dreamina_response_invalid", 502);
|
||||
}
|
||||
const expiresAt = Date.parse(value.expiresAt);
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= now || expiresAt > now + 31 * 60_000) {
|
||||
throw publicError("dreamina_response_invalid", 502);
|
||||
}
|
||||
verification = {
|
||||
verificationUri: value.verificationUri,
|
||||
userCode: value.userCode,
|
||||
expiresAt: value.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "dreamina-cli",
|
||||
state: value.state,
|
||||
installed: value.installed,
|
||||
authenticated: value.authenticated,
|
||||
...(typeof value.version === "string" ? { version: value.version } : {}),
|
||||
...(typeof value.totalCredit === "number" ? { totalCredit: value.totalCredit } : {}),
|
||||
...(typeof value.accountBinding === "string" ? { accountBinding: value.accountBinding } : {}),
|
||||
...(typeof value.sessionEpoch === "number" ? { sessionEpoch: value.sessionEpoch } : {}),
|
||||
...(typeof value.creditObservedAt === "string" ? { creditObservedAt: value.creditObservedAt } : {}),
|
||||
...(code ? { code } : {}),
|
||||
message: publicStatusMessage(value.state),
|
||||
...verification,
|
||||
};
|
||||
}
|
||||
|
||||
function consistentStatus(state: DreaminaCliState, installed: boolean, authenticated: boolean, code: DreaminaLifecycleStatusCode | undefined) {
|
||||
if (state === "missing") return !installed && !authenticated && code === "dreamina_missing";
|
||||
if (state === "installed") return installed && !authenticated && code === "dreamina_login_required";
|
||||
if (state === "login_pending") return installed && !authenticated && code === "dreamina_login_pending";
|
||||
if (state === "authenticated") return installed && authenticated && code === undefined;
|
||||
return installed && !authenticated && code === "dreamina_version_failed";
|
||||
}
|
||||
|
||||
function isDreaminaState(value: unknown): value is DreaminaCliState {
|
||||
return value === "missing" || value === "installed" || value === "login_pending" || value === "authenticated" || value === "error";
|
||||
}
|
||||
|
||||
function isOfficialVerificationUri(value: unknown): value is string {
|
||||
if (typeof value !== "string" || value.length > 2_048) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "https:" && url.hostname === "jimeng.jianying.com" && url.pathname === "/ai-tool/cli-auth" && !url.username && !url.password && !url.hash;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function publicStatusMessage(state: DreaminaCliState) {
|
||||
if (state === "missing") return "未检测到 Dreamina CLI,请先完成本机安装";
|
||||
if (state === "installed") return "Dreamina CLI 已安装,需要登录";
|
||||
if (state === "login_pending") return "请在官方页面确认 Dreamina 登录";
|
||||
if (state === "authenticated") return "Dreamina CLI 已登录";
|
||||
return "已找到 Dreamina CLI,但版本检测失败";
|
||||
}
|
||||
|
||||
function publicError(code: string, status: number) {
|
||||
const messages: Record<string, string> = {
|
||||
...STABLE_ERROR_MESSAGES,
|
||||
dreamina_cancelled: "Dreamina 操作已取消",
|
||||
dreamina_response_invalid: "Dreamina CLI 响应无效",
|
||||
dreamina_runtime_required: "请先连接本机运行时",
|
||||
dreamina_runtime_unreachable: "本机运行时暂时不可用",
|
||||
dreamina_timeout: "Dreamina CLI 请求超时",
|
||||
};
|
||||
return new DreaminaAgentError(code, status, messages[code] ?? "Dreamina CLI 请求失败");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
Reference in new issue
Block a user