feat: move trusted renderers out of app shell
This commit is contained in:
+32
-73
@@ -1,5 +1,3 @@
|
|||||||
import DOMPurify from "dompurify";
|
|
||||||
import { marked } from "marked";
|
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import {
|
import {
|
||||||
type Actor,
|
type Actor,
|
||||||
@@ -11,6 +9,7 @@ import { ConversationStore } from "./runtime/conversation-store";
|
|||||||
import { InteractionKernel } from "./runtime/interaction-kernel";
|
import { InteractionKernel } from "./runtime/interaction-kernel";
|
||||||
import { RendererRegistry } from "./runtime/renderer-registry";
|
import { RendererRegistry } from "./runtime/renderer-registry";
|
||||||
import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter";
|
import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter";
|
||||||
|
import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers";
|
||||||
|
|
||||||
type Session = {
|
type Session = {
|
||||||
api: string;
|
api: string;
|
||||||
@@ -26,7 +25,6 @@ const DEFAULT_API = "http://127.0.0.1:8090";
|
|||||||
let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, uid: "", agentUID: "agent_hermes_main", channelID: "agent_default_channel", channelType: 2, lastSeq: 0, active: false };
|
let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, uid: "", agentUID: "agent_hermes_main", channelID: "agent_default_channel", channelType: 2, lastSeq: 0, active: false };
|
||||||
let transport: TransportAdapter | undefined;
|
let transport: TransportAdapter | undefined;
|
||||||
let polling = false;
|
let polling = false;
|
||||||
let thinking: HTMLElement | undefined;
|
|
||||||
const interactionKernel = new InteractionKernel();
|
const interactionKernel = new InteractionKernel();
|
||||||
const conversationStore = new ConversationStore(localStorage);
|
const conversationStore = new ConversationStore(localStorage);
|
||||||
|
|
||||||
@@ -63,10 +61,6 @@ const chatView = $<HTMLElement>("#chat-view");
|
|||||||
const presence = $<HTMLElement>("#presence");
|
const presence = $<HTMLElement>("#presence");
|
||||||
$<HTMLInputElement>("#api").value = session.api;
|
$<HTMLInputElement>("#api").value = session.api;
|
||||||
|
|
||||||
function scrollLatest(): void { messages.scrollTop = messages.scrollHeight; }
|
|
||||||
function setPresence(value: string): void { presence.textContent = value; }
|
|
||||||
function addSystem(message: string): void { const item = document.createElement("p"); item.className = "system"; item.textContent = message; messages.append(item); scrollLatest(); }
|
|
||||||
|
|
||||||
function newLocalID(prefix: string): string {
|
function newLocalID(prefix: string): string {
|
||||||
// `crypto.randomUUID` is absent from older Safari / WKWebView versions.
|
// `crypto.randomUUID` is absent from older Safari / WKWebView versions.
|
||||||
// A local item id needs uniqueness within this browser store, not a
|
// A local item id needs uniqueness within this browser store, not a
|
||||||
@@ -100,59 +94,19 @@ function loginFailureMessage(reason: unknown): string {
|
|||||||
return message || "登录失败,请稍后重试。";
|
return message || "登录失败,请稍后重试。";
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendBubble(role: "human" | "agent", content: string, markdown = false, delivery = ""): HTMLElement {
|
const rendererContext = new TrustedDOMRendererContext(messages, presence);
|
||||||
const row = document.createElement("article"); row.className = `message-row ${role}`;
|
const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
|
||||||
if (role === "agent") { const avatar = document.createElement("span"); avatar.className = "message-avatar"; avatar.textContent = "✦"; row.append(avatar); }
|
rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item));
|
||||||
const bubble = document.createElement("div"); bubble.className = "bubble";
|
rendererRegistry.register("markdown", (item, context) => context.renderMarkdown(item));
|
||||||
if (markdown) {
|
rendererRegistry.register("agent-status", (item, context) => context.renderAgentStatus(item));
|
||||||
bubble.classList.add("markdown");
|
rendererRegistry.register("progress", (item, context) => context.renderProgress(item));
|
||||||
bubble.innerHTML = DOMPurify.sanitize(marked.parse(content, { gfm: true, breaks: true }) as string, { FORBID_TAGS: ["style", "form", "input", "button"] });
|
rendererRegistry.register("error", (item, context) => context.renderError(item));
|
||||||
bubble.querySelectorAll("a").forEach(link => { link.target = "_blank"; link.rel = "noopener noreferrer"; });
|
rendererRegistry.register("surface", (item, context) => context.renderSurface(item));
|
||||||
} else bubble.textContent = content;
|
rendererRegistry.register("app-call", (item, context) => context.renderAppCall(item));
|
||||||
const meta = document.createElement("small"); meta.className = "meta"; meta.textContent = `${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}${delivery ? ` · ${delivery}` : ""}`;
|
rendererRegistry.register("fallback", (item, context) => context.renderFallback(item));
|
||||||
bubble.append(meta); row.append(bubble); messages.append(row); scrollLatest(); return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showThinking(detail = "正在思考…"): void {
|
|
||||||
thinking?.remove();
|
|
||||||
thinking = appendBubble("agent", detail);
|
|
||||||
thinking.classList.add("thinking");
|
|
||||||
setPresence("正在思考");
|
|
||||||
}
|
|
||||||
function hideThinking(): void { thinking?.remove(); thinking = undefined; }
|
|
||||||
|
|
||||||
function render(item: ConversationItem): void {
|
|
||||||
switch (item.kind) {
|
|
||||||
case "user-message": {
|
|
||||||
const delivery = item.delivery.status === "local_pending" ? "发送中" : item.delivery.status === "failed" ? "发送失败" : "已发送";
|
|
||||||
appendBubble("human", item.text, false, delivery);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "markdown": hideThinking(); appendBubble("agent", item.markdown, true); break;
|
|
||||||
case "agent-status": item.status === "thinking" ? showThinking(item.detail || "正在思考…") : (hideThinking(), setPresence(item.detail || item.status || "在线")); break;
|
|
||||||
case "progress": { const percent = Math.max(0, Math.min(100, item.percent)); addSystem(`${item.title || "Agent 任务"}:${percent}%${item.status ? ` · ${item.status}` : ""}`); break; }
|
|
||||||
case "error": hideThinking(); addSystem(`Agent 错误:${item.message}`); break;
|
|
||||||
case "surface": addSystem(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`); break;
|
|
||||||
case "app-call": addSystem("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。"); break;
|
|
||||||
case "fallback": addSystem(item.reason === "unsupported_type" ? `暂不支持的 Agent 内容:${item.envelope?.type ?? "unknown"}` : "收到无法安全展示的消息。"); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// M0-03 establishes the dispatch boundary. M0-06 will move these individual
|
|
||||||
// DOM renderers out of App Shell; until then the registry keeps the same UI
|
|
||||||
// behavior while ensuring all incoming content traverses the Kernel first.
|
|
||||||
const rendererRegistry = new RendererRegistry<void>();
|
|
||||||
rendererRegistry.register("user-message", render);
|
|
||||||
rendererRegistry.register("markdown", render);
|
|
||||||
rendererRegistry.register("agent-status", render);
|
|
||||||
rendererRegistry.register("progress", render);
|
|
||||||
rendererRegistry.register("error", render);
|
|
||||||
rendererRegistry.register("surface", render);
|
|
||||||
rendererRegistry.register("app-call", render);
|
|
||||||
rendererRegistry.register("fallback", render);
|
|
||||||
|
|
||||||
function renderIncoming(item: ConversationItem): void {
|
function renderIncoming(item: ConversationItem): void {
|
||||||
if (!rendererRegistry.render(item, undefined)) addSystem("收到当前客户端没有可信 Renderer 的内容。");
|
if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。");
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentConversationKey(): string {
|
function currentConversationKey(): string {
|
||||||
@@ -184,7 +138,7 @@ async function syncLoop(): Promise<void> {
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
});
|
});
|
||||||
if (synced.length === 0) {
|
if (synced.length === 0) {
|
||||||
setPresence("已同步,等待消息");
|
rendererContext.setPresence("已同步,等待消息");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,12 +167,12 @@ async function syncLoop(): Promise<void> {
|
|||||||
// Avoid a hot loop if an upstream response repeats a sequence that
|
// Avoid a hot loop if an upstream response repeats a sequence that
|
||||||
// cannot advance the local cursor.
|
// cannot advance the local cursor.
|
||||||
if (!advanced) {
|
if (!advanced) {
|
||||||
setPresence("已同步,等待消息");
|
rendererContext.setPresence("已同步,等待消息");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (session.active) setPresence("同步中断,正在重试…");
|
if (session.active) rendererContext.setPresence("同步中断,正在重试…");
|
||||||
}
|
}
|
||||||
await new Promise(resolve => window.setTimeout(resolve, 1500));
|
await new Promise(resolve => window.setTimeout(resolve, 1500));
|
||||||
}
|
}
|
||||||
@@ -251,10 +205,10 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
|
|||||||
session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true };
|
session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true };
|
||||||
const snapshot = conversationStore.open(currentConversationKey());
|
const snapshot = conversationStore.open(currentConversationKey());
|
||||||
session.lastSeq = snapshot.cursor;
|
session.lastSeq = snapshot.cursor;
|
||||||
messages.replaceChildren();
|
messages.replaceChildren(); rendererContext.reset();
|
||||||
loginView.hidden = true; chatView.hidden = false;
|
loginView.hidden = true; chatView.hidden = false;
|
||||||
for (const stored of snapshot.items) renderIncoming(stored.item);
|
for (const stored of snapshot.items) renderIncoming(stored.item);
|
||||||
addSystem("已进入与 AI Agent 的对话"); setPresence("正在同步消息…"); void syncLoop();
|
rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void syncLoop();
|
||||||
} catch (reason) { error.textContent = loginFailureMessage(reason); }
|
} catch (reason) { error.textContent = loginFailureMessage(reason); }
|
||||||
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
|
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
|
||||||
});
|
});
|
||||||
@@ -264,7 +218,7 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
|||||||
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
|
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (!session.uid) {
|
if (!session.uid) {
|
||||||
addSystem("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
|
rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
|
||||||
input.focus();
|
input.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -274,11 +228,16 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
|||||||
// A browser storage failure must never prevent the user from seeing or
|
// A browser storage failure must never prevent the user from seeing or
|
||||||
// submitting their message. The Store remains the durable source when it
|
// submitting their message. The Store remains the durable source when it
|
||||||
// is available; the server sync is the recovery path when it is not.
|
// is available; the server sync is the recovery path when it is not.
|
||||||
const row = appendBubble("human", text, false, "发送中");
|
const localItem: ConversationItem = {
|
||||||
const meta = row.querySelector<HTMLElement>(".meta");
|
kind: "user-message",
|
||||||
|
id: localID,
|
||||||
|
text,
|
||||||
|
delivery: { status: "local_pending", updated_at: createdAt },
|
||||||
|
};
|
||||||
|
renderIncoming(localItem);
|
||||||
let storedLocally = true;
|
let storedLocally = true;
|
||||||
try { conversationStore.appendLocalText(localID, text, createdAt); }
|
try { conversationStore.appendLocalText(localID, text, createdAt); }
|
||||||
catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); }
|
catch (reason) { storedLocally = false; rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
|
||||||
try {
|
try {
|
||||||
if (!transport) throw new Error("当前会话传输尚未初始化,请退出后重新登录。");
|
if (!transport) throw new Error("当前会话传输尚未初始化,请退出后重新登录。");
|
||||||
const sequence = await transport.sendText({
|
const sequence = await transport.sendText({
|
||||||
@@ -289,23 +248,23 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
|||||||
});
|
});
|
||||||
if (sequence && storedLocally) {
|
if (sequence && storedLocally) {
|
||||||
try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); }
|
try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); }
|
||||||
catch (reason) { storedLocally = false; addSystem(storageFailureMessage(reason)); }
|
catch (reason) { storedLocally = false; rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
|
||||||
}
|
}
|
||||||
if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 已发送`;
|
rendererContext.updateUserDelivery(localID, { status: "submitted", updated_at: new Date().toISOString(), ...(sequence ? { transport_id: String(sequence) } : {}) });
|
||||||
}
|
}
|
||||||
catch (reason) {
|
catch (reason) {
|
||||||
const message = reason instanceof Error ? reason.message : "发送失败";
|
const message = reason instanceof Error ? reason.message : "发送失败";
|
||||||
if (storedLocally) {
|
if (storedLocally) {
|
||||||
try { conversationStore.markFailed(localID, message, new Date().toISOString()); }
|
try { conversationStore.markFailed(localID, message, new Date().toISOString()); }
|
||||||
catch (storageReason) { addSystem(storageFailureMessage(storageReason)); }
|
catch (storageReason) { rendererContext.renderSystemMessage(storageFailureMessage(storageReason)); }
|
||||||
}
|
}
|
||||||
if (meta) meta.textContent = `${meta.textContent?.replace(" · 发送中", "") ?? ""} · 发送失败`;
|
rendererContext.updateUserDelivery(localID, { status: "failed", updated_at: new Date().toISOString(), error: message, retryable: true });
|
||||||
addSystem(message);
|
rendererContext.renderSystemMessage(message);
|
||||||
}
|
}
|
||||||
finally { send.disabled = false; input.focus(); }
|
finally { send.disabled = false; input.focus(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; transport = undefined; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; });
|
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; transport = undefined; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; });
|
||||||
|
|
||||||
export function makeProtocolEvent(
|
export function makeProtocolEvent(
|
||||||
type: string,
|
type: string,
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import type {
|
||||||
|
ConversationItem,
|
||||||
|
DeliveryState,
|
||||||
|
} from "../protocol";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted presentation context for the first-party chat shell. It owns only
|
||||||
|
* DOM nodes and display-local state; it deliberately has no Store, Transport,
|
||||||
|
* Kernel, Tauri bridge, or capability reference.
|
||||||
|
*/
|
||||||
|
export class TrustedDOMRendererContext {
|
||||||
|
private thinking: HTMLElement | undefined;
|
||||||
|
private readonly userRows = new Map<string, HTMLElement>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly messages: HTMLElement,
|
||||||
|
private readonly presence: HTMLElement,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.thinking = undefined;
|
||||||
|
this.userRows.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderUserMessage(item: Extract<ConversationItem, { kind: "user-message" }>): void {
|
||||||
|
const row = this.appendBubble("human", item.text, false, deliveryLabel(item.delivery));
|
||||||
|
if (item.id) this.userRows.set(item.id, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderMarkdown(item: Extract<ConversationItem, { kind: "markdown" }>): void {
|
||||||
|
this.hideThinking();
|
||||||
|
this.appendBubble("agent", item.markdown, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderAgentStatus(item: Extract<ConversationItem, { kind: "agent-status" }>): void {
|
||||||
|
if (item.status === "thinking") this.showThinking(item.detail || "正在思考…");
|
||||||
|
else {
|
||||||
|
this.hideThinking();
|
||||||
|
this.setPresence(item.detail || item.status || "在线");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderProgress(item: Extract<ConversationItem, { kind: "progress" }>): void {
|
||||||
|
const percent = Math.max(0, Math.min(100, item.percent));
|
||||||
|
this.renderSystemMessage(`${item.title || "Agent 任务"}:${percent}%${item.status ? ` · ${item.status}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderError(item: Extract<ConversationItem, { kind: "error" }>): void {
|
||||||
|
this.hideThinking();
|
||||||
|
this.renderSystemMessage(`Agent 错误:${item.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderSurface(item: Extract<ConversationItem, { kind: "surface" }>): void {
|
||||||
|
this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderAppCall(_item: Extract<ConversationItem, { kind: "app-call" }>): void {
|
||||||
|
this.renderSystemMessage("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。");
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderFallback(item: Extract<ConversationItem, { kind: "fallback" }>): void {
|
||||||
|
this.renderSystemMessage(item.reason === "unsupported_type"
|
||||||
|
? `暂不支持的 Agent 内容:${item.envelope?.type ?? "unknown"}`
|
||||||
|
: "收到无法安全展示的消息。");
|
||||||
|
}
|
||||||
|
|
||||||
|
public renderSystemMessage(message: string): void {
|
||||||
|
const item = document.createElement("p");
|
||||||
|
item.className = "system";
|
||||||
|
item.textContent = message;
|
||||||
|
this.messages.append(item);
|
||||||
|
this.scrollLatest();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates the visible local echo without requiring DOM access in App Shell. */
|
||||||
|
public updateUserDelivery(localID: string, delivery: DeliveryState): void {
|
||||||
|
const meta = this.userRows.get(localID)?.querySelector<HTMLElement>(".meta");
|
||||||
|
if (!meta) return;
|
||||||
|
const time = meta.textContent?.split(" · ")[0] ?? "";
|
||||||
|
meta.textContent = `${time} · ${deliveryLabel(delivery)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPresence(value: string): void {
|
||||||
|
this.presence.textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendBubble(role: "human" | "agent", content: string, markdown = false, delivery = ""): HTMLElement {
|
||||||
|
const row = document.createElement("article");
|
||||||
|
row.className = `message-row ${role}`;
|
||||||
|
if (role === "agent") {
|
||||||
|
const avatar = document.createElement("span");
|
||||||
|
avatar.className = "message-avatar";
|
||||||
|
avatar.textContent = "✦";
|
||||||
|
row.append(avatar);
|
||||||
|
}
|
||||||
|
const bubble = document.createElement("div");
|
||||||
|
bubble.className = "bubble";
|
||||||
|
if (markdown) {
|
||||||
|
bubble.classList.add("markdown");
|
||||||
|
bubble.innerHTML = DOMPurify.sanitize(marked.parse(content, { gfm: true, breaks: true }) as string, {
|
||||||
|
FORBID_TAGS: ["style", "form", "input", "button"],
|
||||||
|
});
|
||||||
|
bubble.querySelectorAll("a").forEach(link => {
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
bubble.textContent = content;
|
||||||
|
}
|
||||||
|
const meta = document.createElement("small");
|
||||||
|
meta.className = "meta";
|
||||||
|
meta.textContent = `${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}${delivery ? ` · ${delivery}` : ""}`;
|
||||||
|
bubble.append(meta);
|
||||||
|
row.append(bubble);
|
||||||
|
this.messages.append(row);
|
||||||
|
this.scrollLatest();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private showThinking(detail: string): void {
|
||||||
|
this.thinking?.remove();
|
||||||
|
this.thinking = this.appendBubble("agent", detail);
|
||||||
|
this.thinking.classList.add("thinking");
|
||||||
|
this.setPresence("正在思考");
|
||||||
|
}
|
||||||
|
|
||||||
|
private hideThinking(): void {
|
||||||
|
this.thinking?.remove();
|
||||||
|
this.thinking = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollLatest(): void {
|
||||||
|
this.messages.scrollTop = this.messages.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliveryLabel(delivery: DeliveryState): string {
|
||||||
|
if (delivery.status === "local_pending") return "发送中";
|
||||||
|
if (delivery.status === "failed") return "发送失败";
|
||||||
|
return "已发送";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user