diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index a5d03fa..d03d448 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 0607e546de923e9377b04fb841d01384b1a666c1 + ref: 69a9616f6f48f19dc109a6b3eef8fc1000825829 path: .akashic-core - uses: actions/setup-python@v5 with: diff --git a/dashboard_panel.css b/dashboard_panel.css deleted file mode 100644 index 28c0153..0000000 --- a/dashboard_panel.css +++ /dev/null @@ -1,140 +0,0 @@ -.feedback-detail { - display: grid; - gap: 20px; - padding: 24px; - color: var(--ak-color-text-primary); -} - -.feedback-detail__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} - -.feedback-detail__header p { - margin: 0 0 4px; - color: var(--ak-color-action-primary); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; -} - -.feedback-detail__header h2 { - margin: 0; - font-size: 20px; - letter-spacing: -0.02em; -} - -.feedback-detail__header span { - display: block; - margin-block-start: 5px; - color: var(--ak-color-text-muted); - font-size: 12px; -} - -.feedback-summary { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - border-block: 1px solid var(--ak-color-border-default); -} - -.feedback-summary > div { - display: grid; - gap: 5px; - padding: 14px; -} - -.feedback-summary > div + div { border-inline-start: 1px solid var(--ak-color-border-default); } -.feedback-summary span { color: var(--ak-color-text-muted); font-size: 11px; } -.feedback-summary strong { font: 650 18px var(--mono); } - -.feedback-timeline { - display: grid; - gap: 0; -} - -.feedback-timeline > h3 { - margin: 0 0 10px; - font-size: 15px; -} - -.feedback-step { - position: relative; - display: grid; - grid-template-columns: 30px minmax(0, 1fr); - gap: 12px; - padding-block: 10px; -} - -.feedback-step:not(:last-child)::after { - position: absolute; - top: 40px; - bottom: -10px; - left: 14px; - width: 1px; - content: ""; - background: var(--ak-color-border-strong); -} - -.feedback-step__index { - display: grid; - width: 30px; - height: 30px; - place-items: center; - border: 1px solid var(--ak-color-border-strong); - border-radius: 50%; - color: var(--ak-color-text-muted); - background: var(--ak-color-bg-canvas); - font: 650 11px var(--mono); -} - -.feedback-step.is-emphasis .feedback-step__index { - border-color: var(--ak-color-action-primary); - color: var(--ak-color-on-action-primary); - background: var(--ak-color-action-soft); -} - -.feedback-step h4 { margin: 3px 0 6px; font-size: 13px; } -.feedback-step p { - margin: 0; - color: var(--ak-color-text-secondary); - font-size: 13px; - line-height: 1.65; - white-space: pre-wrap; -} - -.feedback-step.is-emphasis p { - padding: 12px; - border-inline-start: 3px solid var(--ak-color-action-primary); - background: var(--ak-color-action-soft); -} - -.feedback-technical { - border-block-start: 1px solid var(--ak-color-border-default); -} - -.feedback-technical summary { - padding: 14px 2px; - font-size: 13px; - font-weight: 650; - cursor: pointer; -} - -.feedback-technical summary:focus-visible { - outline: 2px solid var(--ak-color-action-primary); - outline-offset: 2px; -} - -.feedback-technical dl { display: grid; gap: 8px; margin: 0 0 14px; } -.feedback-technical dl > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 12px; } -.feedback-technical dt { color: var(--ak-color-text-muted); font-size: 12px; } -.feedback-technical dd { overflow-wrap: anywhere; margin: 0; font: 12px var(--mono); } - -@media (max-width: 720px) { - .feedback-detail { padding: 18px; } - .feedback-detail__header { flex-direction: column; } - .feedback-summary { grid-template-columns: 1fr; } - .feedback-summary > div + div { border-block-start: 1px solid var(--ak-color-border-default); border-inline-start: 0; } - .feedback-technical dl > div { grid-template-columns: 1fr; gap: 3px; } -} diff --git a/dashboard_panel.js b/dashboard_panel.js deleted file mode 100644 index 6386f21..0000000 --- a/dashboard_panel.js +++ /dev/null @@ -1,169 +0,0 @@ -// ../proactive_feedback/dashboard_panel.tsx -import { Chip, api } from "@akashic/dashboard-ui"; -import { jsx, jsxs } from "react/jsx-runtime"; -function _score(value) { - return typeof value === "number" ? value.toFixed(3) : "-"; -} -function _shortTs(value) { - const text = String(value || ""); - if (!text) return "-"; - const d = new Date(text); - if (Number.isNaN(d.getTime())) return text; - return `${d.getMonth() + 1}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; -} -function _lag(value) { - if (typeof value !== "number") return "-"; - if (value < 60) return `${value}s`; - if (value < 3600) return `${Math.round(value / 60)}m`; - return `${(value / 3600).toFixed(1)}h`; -} -function _tone(type) { - if (type === "explicit_quote") return "accent"; - if (type === "topic_follow") return "success"; - if (type === "unscored") return "warning"; - if (type === "no_topic_follow") return "muted"; - return "muted"; -} -function _feedbackTypeLabel(value) { - const type = String(value || ""); - if (type === "explicit_quote") return "\u660E\u786E\u5F15\u7528"; - if (type === "topic_follow") return "\u8BDD\u9898\u5EF6\u7EED"; - if (type === "no_topic_follow") return "\u672A\u5EF6\u7EED\u8BDD\u9898"; - if (type === "unscored") return "\u5F85\u8BC4\u5206"; - return type || "-"; -} -function _confidenceLabel(value) { - const confidence = String(value || ""); - if (confidence === "gold") return "\u91D1\u6807"; - if (confidence === "high") return "\u9AD8"; - if (confidence === "medium") return "\u4E2D"; - if (confidence === "low") return "\u4F4E"; - return confidence || "-"; -} -function _escape(value) { - return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); -} -function _cellText(value) { - const text = String(value || "").trim(); - return _escape(text || "-"); -} -function _typeCell(value) { - const type = String(value || ""); - const tone = type === "explicit_quote" ? "accent" : type === "topic_follow" ? "success" : type === "unscored" ? "warning" : "muted"; - return `${_escape(_feedbackTypeLabel(type))}`; -} -function _confidenceTone(value) { - const confidence = String(value || ""); - if (confidence === "gold" || confidence === "high") return "success"; - if (confidence === "medium") return "warning"; - return "muted"; -} -function _confidenceCell(value) { - const confidence = String(value || "-"); - return `${_escape(_confidenceLabel(confidence))}`; -} -function FeedbackDetail(props) { - const item = props.item; - if (!item) { - return /* @__PURE__ */ jsxs("div", { className: "detail-empty", children: [ - /* @__PURE__ */ jsx("div", { className: "detail-empty-title", children: "\u53CD\u9988\u8BE6\u60C5" }), - /* @__PURE__ */ jsx("div", { className: "detail-empty-text", children: "\u70B9\u5F00\u4E00\u6761\u8BB0\u5F55\u540E\uFF0C\u8FD9\u91CC\u4F1A\u663E\u793A\u7528\u6237\u56DE\u590D\u3001\u547D\u4E2D\u7684 proactive \u548C\u52A9\u624B\u540E\u7EED\u56DE\u590D\u3002" }) - ] }); - } - const type = String(item.feedback_type || ""); - return /* @__PURE__ */ jsxs("main", { className: "feedback-detail", "aria-labelledby": "feedback-detail-title", children: [ - /* @__PURE__ */ jsxs("header", { className: "feedback-detail__header", children: [ - /* @__PURE__ */ jsxs("div", { children: [ - /* @__PURE__ */ jsx("p", { children: "\u4E3B\u52A8\u6D88\u606F\u6548\u679C" }), - /* @__PURE__ */ jsx("h2", { id: "feedback-detail-title", children: "\u7528\u6237\u5982\u4F55\u56DE\u5E94\u8FD9\u6B21\u4E3B\u52A8\u89E6\u8FBE" }), - /* @__PURE__ */ jsx("span", { children: String(item.session_key || "\u672A\u5173\u8054\u4F1A\u8BDD") }) - ] }), - /* @__PURE__ */ jsx(Chip, { tone: _tone(type), children: _feedbackTypeLabel(type) }) - ] }), - /* @__PURE__ */ jsxs("section", { className: "feedback-summary", "aria-label": "\u53CD\u9988\u5224\u65AD\u6458\u8981", children: [ - /* @__PURE__ */ jsx(SummaryMetric, { label: "\u7F6E\u4FE1\u5EA6", value: _confidenceLabel(item.confidence) }), - /* @__PURE__ */ jsx(SummaryMetric, { label: "\u54CD\u5E94\u5EF6\u8FDF", value: _lag(item.lag_seconds) }), - /* @__PURE__ */ jsx(SummaryMetric, { label: "PUA \u5206\u6570", value: _score(item.pua_score) }) - ] }), - /* @__PURE__ */ jsxs("section", { className: "feedback-timeline", "aria-labelledby": "feedback-timeline-title", children: [ - /* @__PURE__ */ jsx("h3", { id: "feedback-timeline-title", children: "\u5BF9\u8BDD\u94FE\u8DEF" }), - /* @__PURE__ */ jsx(TimelineStep, { index: "1", title: "\u4E3B\u52A8\u6D88\u606F", text: String(item.proactive_preview || item.quoted_preview || "") }), - /* @__PURE__ */ jsx(TimelineStep, { index: "2", title: "\u7528\u6237\u53CD\u9988", text: String(item.user_reply_preview || item.user_preview || ""), emphasis: true }), - /* @__PURE__ */ jsx(TimelineStep, { index: "3", title: "\u52A9\u624B\u540E\u7EED", text: String(item.assistant_preview || "") }) - ] }), - /* @__PURE__ */ jsxs("details", { className: "feedback-technical", children: [ - /* @__PURE__ */ jsx("summary", { children: "\u67E5\u770B\u5339\u914D\u4F9D\u636E\u4E0E\u6D88\u606F\u6807\u8BC6" }), - /* @__PURE__ */ jsxs("dl", { children: [ - /* @__PURE__ */ jsxs("div", { children: [ - /* @__PURE__ */ jsx("dt", { children: "\u5339\u914D\u65B9\u5F0F" }), - /* @__PURE__ */ jsx("dd", { children: String(item.matched_by || "-") }) - ] }), - /* @__PURE__ */ jsxs("div", { children: [ - /* @__PURE__ */ jsx("dt", { children: "\u7528\u6237\u6D88\u606F ID" }), - /* @__PURE__ */ jsx("dd", { children: String(item.user_message_id || "-") }) - ] }), - /* @__PURE__ */ jsxs("div", { children: [ - /* @__PURE__ */ jsx("dt", { children: "\u4E3B\u52A8\u6D88\u606F ID" }), - /* @__PURE__ */ jsx("dd", { children: String(item.proactive_message_id || "-") }) - ] }) - ] }) - ] }) - ] }); -} -function SummaryMetric(props) { - return /* @__PURE__ */ jsxs("div", { children: [ - /* @__PURE__ */ jsx("span", { children: props.label }), - /* @__PURE__ */ jsx("strong", { children: props.value }) - ] }); -} -function TimelineStep(props) { - return /* @__PURE__ */ jsxs("div", { className: `feedback-step${props.emphasis ? " is-emphasis" : ""}`, children: [ - /* @__PURE__ */ jsx("span", { className: "feedback-step__index", children: props.index }), - /* @__PURE__ */ jsxs("div", { children: [ - /* @__PURE__ */ jsx("h4", { children: props.title }), - /* @__PURE__ */ jsx("p", { children: props.text || "\u6CA1\u6709\u53EF\u5C55\u793A\u7684\u5185\u5BB9" }) - ] }) - ] }); -} -window.AkashicDashboard.registerPlugin({ - id: "proactive_feedback", - label: "\u4E3B\u52A8\u53CD\u9988", - viewLabel: "\u4E3B\u52A8\u53CD\u9988", - pageSize: 50, - rowKey: "id", - countTitle(total) { - return `\u5171 ${total} \u6761\u53CD\u9988`; - }, - columns: [ - { key: "created_at", label: "\u65F6\u95F4", width: 96, fmt: "mono-time", cellClass: "mono cell-time", rawTitle: true }, - { key: "feedback_type", label: "\u7C7B\u578B", width: 126, renderCell: _typeCell }, - { key: "confidence", label: "\u7F6E\u4FE1\u5EA6", width: 84, renderCell: _confidenceCell }, - { key: "lag_seconds", label: "\u5EF6\u8FDF", width: 68, fmt: "lag", cellClass: "mono cell-metric", align: "right" }, - { key: "user_reply_preview", label: "\u7528\u6237\u56DE\u590D", flex: true, renderCell: _cellText, cellClass: "content-preview", rawTitle: true }, - { key: "proactive_preview", label: "\u547D\u4E2D\u5185\u5BB9", flex: true, renderCell: _cellText, cellClass: "content-preview", rawTitle: true } - ], - async getCount() { - try { - const overview = await api("/api/dashboard/proactive-feedback/overview"); - return overview.total || 0; - } catch { - return null; - } - }, - async fetchPage({ page, pageSize }) { - const params = new URLSearchParams(); - params.set("page", String(page)); - params.set("page_size", String(pageSize)); - const data = await api(`/api/dashboard/proactive-feedback/events?${params.toString()}`); - return { items: data.items || [], total: data.total || 0 }; - }, - async fetchDetail(item) { - return api(`/api/dashboard/proactive-feedback/events/${item.id}`); - }, - Detail: FeedbackDetail, - formatters: { - score: (value) => _score(value), - lag: (value) => _lag(value), - "mono-time": (value) => _shortTs(value) - } -}); diff --git a/dashboard_panel.tsx b/dashboard_panel.tsx deleted file mode 100644 index a1ac814..0000000 --- a/dashboard_panel.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/// -import { type ReactElement } from "react"; -import { Chip, api } from "@akashic/dashboard-ui"; - -interface Overview { - total: number; -} - -interface FetchPage { - items: Record[]; - total: number; -} - -function _score(value: unknown): string { - return typeof value === "number" ? value.toFixed(3) : "-"; -} - -function _shortTs(value: unknown): string { - const text = String(value || ""); - if (!text) return "-"; - const d = new Date(text); - if (Number.isNaN(d.getTime())) return text; - return `${d.getMonth() + 1}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; -} - -function _lag(value: unknown): string { - if (typeof value !== "number") return "-"; - if (value < 60) return `${value}s`; - if (value < 3600) return `${Math.round(value / 60)}m`; - return `${(value / 3600).toFixed(1)}h`; -} - -function _tone(type: string): "success" | "warning" | "danger" | "accent" | "muted" { - if (type === "explicit_quote") return "accent"; - if (type === "topic_follow") return "success"; - if (type === "unscored") return "warning"; - if (type === "no_topic_follow") return "muted"; - return "muted"; -} - -function _feedbackTypeLabel(value: unknown): string { - const type = String(value || ""); - if (type === "explicit_quote") return "明确引用"; - if (type === "topic_follow") return "话题延续"; - if (type === "no_topic_follow") return "未延续话题"; - if (type === "unscored") return "待评分"; - return type || "-"; -} - -function _confidenceLabel(value: unknown): string { - const confidence = String(value || ""); - if (confidence === "gold") return "金标"; - if (confidence === "high") return "高"; - if (confidence === "medium") return "中"; - if (confidence === "low") return "低"; - return confidence || "-"; -} - -function _escape(value: unknown): string { - return String(value ?? "") - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function _cellText(value: unknown): string { - const text = String(value || "").trim(); - return _escape(text || "-"); -} - -function _typeCell(value: unknown): string { - const type = String(value || ""); - const tone = type === "explicit_quote" ? "accent" : type === "topic_follow" ? "success" : type === "unscored" ? "warning" : "muted"; - return `${_escape(_feedbackTypeLabel(type))}`; -} - -function _confidenceTone(value: unknown): "success" | "warning" | "muted" { - const confidence = String(value || ""); - if (confidence === "gold" || confidence === "high") return "success"; - if (confidence === "medium") return "warning"; - return "muted"; -} - -function _confidenceCell(value: unknown): string { - const confidence = String(value || "-"); - return `${_escape(_confidenceLabel(confidence))}`; -} - -function FeedbackDetail(props: { item: Record | null }): ReactElement { - const item = props.item; - if (!item) { - return
反馈详情
点开一条记录后,这里会显示用户回复、命中的 proactive 和助手后续回复。
; - } - const type = String(item.feedback_type || ""); - return ( -
-
-
-

主动消息效果

-

用户如何回应这次主动触达

- {String(item.session_key || "未关联会话")} -
- {_feedbackTypeLabel(type)} -
- -
- - - -
- -
-

对话链路

- - - -
- -
- 查看匹配依据与消息标识 -
-
匹配方式
{String(item.matched_by || "-")}
-
用户消息 ID
{String(item.user_message_id || "-")}
-
主动消息 ID
{String(item.proactive_message_id || "-")}
-
-
-
- ); -} - -function SummaryMetric(props: { label: string; value: string }): ReactElement { - return
{props.label}{props.value}
; -} - -function TimelineStep(props: { index: string; title: string; text: string; emphasis?: boolean }): ReactElement { - return ( -
- {props.index} -
-

{props.title}

-

{props.text || "没有可展示的内容"}

-
-
- ); -} - -window.AkashicDashboard.registerPlugin({ - id: "proactive_feedback", - label: "主动反馈", - viewLabel: "主动反馈", - pageSize: 50, - rowKey: "id", - - countTitle(total: number): string { - return `共 ${total} 条反馈`; - }, - - columns: [ - { key: "created_at", label: "时间", width: 96, fmt: "mono-time", cellClass: "mono cell-time", rawTitle: true }, - { key: "feedback_type", label: "类型", width: 126, renderCell: _typeCell }, - { key: "confidence", label: "置信度", width: 84, renderCell: _confidenceCell }, - { key: "lag_seconds", label: "延迟", width: 68, fmt: "lag", cellClass: "mono cell-metric", align: "right" }, - { key: "user_reply_preview", label: "用户回复", flex: true, renderCell: _cellText, cellClass: "content-preview", rawTitle: true }, - { key: "proactive_preview", label: "命中内容", flex: true, renderCell: _cellText, cellClass: "content-preview", rawTitle: true }, - ], - - async getCount(): Promise { - try { - const overview = await api("/api/dashboard/proactive-feedback/overview"); - return overview.total || 0; - } catch { - return null; - } - }, - - async fetchPage({ page, pageSize }: { page: number; pageSize: number }) { - const params = new URLSearchParams(); - params.set("page", String(page)); - params.set("page_size", String(pageSize)); - const data = await api(`/api/dashboard/proactive-feedback/events?${params.toString()}`); - return { items: data.items || [], total: data.total || 0 }; - }, - - async fetchDetail(item: Record) { - return api>(`/api/dashboard/proactive-feedback/events/${item.id}`); - }, - - Detail: FeedbackDetail, - - formatters: { - score: (value: unknown) => _score(value), - lag: (value: unknown) => _lag(value), - "mono-time": (value: unknown) => _shortTs(value), - }, -}); diff --git a/plugin.py b/plugin.py index d27808f..cb21161 100644 --- a/plugin.py +++ b/plugin.py @@ -65,6 +65,12 @@ drift_skill_roots: tuple[str, ...] = () workspace_roots: tuple[str, ...] = () dashboard_module = "dashboard.py" +web_module = "web_module.js" +web_requires = ("workbench.panels.v1",) +web_provides = () +web_contract_digests = { + "workbench.panels.v1": "724b282c22c4b3f3a36967ab664c4dfd8bce4257665f99459000306938caf527", +} async def apply(ctx: Context, config: object) -> None: diff --git a/tests/test_plugin.py b/tests/test_plugin.py index e1046aa..eb4022b 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1122,8 +1122,10 @@ async def test_plugin_installs_and_loads_from_ordinary_cache(tmp_path: Path) -> core_plugins = Path(inspect.getfile(PluginManager)).parents[2] / "plugins" manager = PluginManager( plugin_dirs=[ + core_plugins / "shell_ui", core_plugins / "models", core_plugins / "openai_compatible", + core_plugins / "workbench_ui", ], event_bus=EventBus(), tool_registry=None, @@ -1162,6 +1164,8 @@ async def test_manager_stable_candidate_ui_dashboard_and_cleanup(tmp_path: Path) "scorer.py", "mobile_panel.js", "mobile_panel.css", + "web_module.js", + "web_module.css", "akashic.plugin.toml", ): shutil.copy2(Path(__file__).parents[1] / filename, plugin_dir / filename) @@ -1172,8 +1176,10 @@ async def test_manager_stable_candidate_ui_dashboard_and_cleanup(tmp_path: Path) manager = PluginManager( plugin_dirs=[ tmp_path / "plugins", + core_plugins / "shell_ui", core_plugins / "models", core_plugins / "openai_compatible", + core_plugins / "workbench_ui", ], event_bus=EventBus(), tool_registry=None, @@ -1231,9 +1237,12 @@ async def test_manager_stable_candidate_ui_dashboard_and_cleanup(tmp_path: Path) candidate_snapshot = candidate.runtime_snapshot assert candidate_snapshot.mobile_ui_registry is not None dashboard_host.prepare_snapshot(candidate_snapshot) - assert len(candidate_snapshot.dashboard_bindings) == 1 - binding = candidate_snapshot.dashboard_bindings[0] - assert isinstance(binding, DashboardBinding) + binding = next( + item + for item in candidate_snapshot.dashboard_bindings + if isinstance(item, DashboardBinding) + and item.plugin_id == "proactive_feedback" + ) assert binding.validation is True assert binding.runtime_data_root is not None assert binding.runtime_data_root != formal_data.resolve() diff --git a/web_module.css b/web_module.css new file mode 100644 index 0000000..2bcdc39 --- /dev/null +++ b/web_module.css @@ -0,0 +1,37 @@ +.proactive-feedback-workbench-panel { max-width: 1180px; margin-inline: auto; } +.proactive-feedback-workbench-panel > header { display: flex; align-items: end; justify-content: space-between; gap: 20px; } +.proactive-feedback-workbench-panel h1, .proactive-feedback-workbench-panel h2, .proactive-feedback-workbench-panel h3, .proactive-feedback-workbench-panel h4, .proactive-feedback-workbench-panel p { margin: 0; } +.proactive-feedback-workbench-panel > header p, .proactive-feedback-panel-row span, .proactive-feedback-panel-row small, .proactive-feedback-workbench-panel article > header span { color: var(--ak-ink-secondary); } +.proactive-feedback-workbench-panel button, .proactive-feedback-workbench-panel select { min-height: 44px; } +.proactive-feedback-overview { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin-block: 16px; background: var(--ak-rule-subtle); } +.proactive-feedback-overview div, .proactive-feedback-overview > p { margin: 0; padding: 14px; background: var(--ak-paper-quiet); } +.proactive-feedback-overview span { display: block; color: var(--ak-ink-secondary); font-size: 0.78rem; } +.proactive-feedback-overview strong { display: block; margin-block-start: 4px; } +.proactive-feedback-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 16px; } +.proactive-feedback-toolbar label { display: grid; gap: 4px; font-size: 0.8rem; } +.proactive-feedback-toolbar p { color: var(--ak-ink-secondary); } +.proactive-feedback-panel-grid { display: grid; grid-template-columns: minmax(280px, 0.9fr) minmax(340px, 1.1fr); gap: 24px; margin-block-start: 16px; } +.proactive-feedback-panel-row { display: grid; width: 100%; gap: 4px; border: 0; border-block-end: 1px solid var(--ak-rule-subtle); padding: 12px; color: inherit; background: transparent; text-align: start; cursor: pointer; } +@media (hover: hover) { .proactive-feedback-panel-row:hover { background: var(--ak-paper-quiet); } } +.proactive-feedback-panel-grid footer { display: flex; align-items: center; justify-content: center; gap: 12px; padding-block: 16px; } +.proactive-feedback-panel-grid > article { min-width: 0; border-inline-start: 1px solid var(--ak-rule-subtle); padding-inline-start: 24px; } +.proactive-feedback-panel-grid article > header { display: flex; align-items: start; justify-content: space-between; gap: 16px; } +.proactive-feedback-panel-grid article > header p { color: var(--ak-ink-secondary); font-size: 0.8rem; } +.proactive-feedback-detail-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } +.proactive-feedback-detail-metrics div { padding: 12px; background: var(--ak-paper-quiet); } +.proactive-feedback-detail-metrics dt { color: var(--ak-ink-secondary); font-size: 0.8rem; } +.proactive-feedback-detail-metrics dd { margin: 4px 0 0; font-family: ui-monospace, monospace; } +.proactive-feedback-timeline { display: grid; gap: 10px; margin-block: 18px; } +.proactive-feedback-step { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 12px; } +.proactive-feedback-step > span { display: grid; width: 30px; height: 30px; place-items: center; border: 1px solid var(--ak-rule-subtle); border-radius: 50%; color: var(--ak-ink-secondary); font-family: ui-monospace, monospace; } +.proactive-feedback-step h4 { margin-block: 4px; } +.proactive-feedback-step p { color: var(--ak-ink-secondary); white-space: pre-wrap; } +.proactive-feedback-step.is-emphasis p { padding: 12px; border-inline-start: 3px solid var(--ak-rule-focus); background: var(--ak-paper-quiet); } +.proactive-feedback-panel-grid details { border-block-start: 1px solid var(--ak-rule-subtle); padding-block-start: 12px; } +.proactive-feedback-panel-grid summary { min-height: 44px; cursor: pointer; } +.proactive-feedback-technical { display: grid; gap: 8px; } +.proactive-feedback-technical div { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 12px; } +.proactive-feedback-technical dt { color: var(--ak-ink-secondary); font-size: 0.8rem; } +.proactive-feedback-technical dd { overflow-wrap: anywhere; margin: 0; font-family: ui-monospace, monospace; } +.proactive-feedback-workbench-panel :focus-visible { outline: 2px solid var(--ak-rule-focus); outline-offset: 2px; } +@media (max-width: 820px) { .proactive-feedback-workbench-panel > header, .proactive-feedback-toolbar { align-items: start; flex-direction: column; } .proactive-feedback-overview, .proactive-feedback-detail-metrics, .proactive-feedback-panel-grid { grid-template-columns: 1fr; } .proactive-feedback-panel-grid > article { border-inline-start: 0; padding-inline-start: 0; } } diff --git a/web_module.js b/web_module.js new file mode 100644 index 0000000..56c2fe1 --- /dev/null +++ b/web_module.js @@ -0,0 +1,189 @@ +export function activate(ctx) { + return ctx.ui.inject("workbench.panels.v1", (mount) => mount.register({ + id: "proactive-feedback", + label: "主动反馈", + order: 50, + render(host) { + const panel = document.createElement("section"); + panel.className = "proactive-feedback-workbench-panel"; + panel.innerHTML = `

主动反馈

查看主动消息是否被继续,以及对应的回应链路。

正在读取反馈总览…

选择一条反馈查看完整回应链路。

`; + host.replaceChildren(panel); + const overview = panel.querySelector("[data-overview]"); + const refresh = panel.querySelector("[data-refresh]"); + const filter = panel.querySelector("[data-filter]"); + const status = panel.querySelector("[data-status]"); + const list = panel.querySelector("[data-list]"); + const detail = panel.querySelector("[data-detail]"); + const pageText = panel.querySelector("[data-page]"); + const previous = panel.querySelector("[data-previous]"); + const next = panel.querySelector("[data-next]"); + let page = 1; + let total = 0; + let disposed = false; + let overviewRequest = new AbortController(); + let listRequest = new AbortController(); + let detailRequest = new AbortController(); + + const loadOverview = async () => { + overviewRequest.abort(); + overviewRequest = new AbortController(); + const request = overviewRequest; + overview.textContent = "正在读取反馈总览…"; + try { + const data = await json(ctx, "/api/dashboard/proactive-feedback/overview", request.signal); + if (disposed || request.signal.aborted) return; + overview.innerHTML = renderOverview(data); + } catch (reason) { + if (!disposed && !request.signal.aborted) showError(overview, reason); + } + }; + + const loadList = async () => { + listRequest.abort(); + listRequest = new AbortController(); + const request = listRequest; + const requestedPage = page; + const params = new URLSearchParams({page: String(requestedPage), page_size: "25"}); + if (filter.value) params.set("feedback_type", filter.value); + status.textContent = "正在读取反馈记录…"; + try { + const data = await json(ctx, `/api/dashboard/proactive-feedback/events?${params}`, request.signal); + if (disposed || request.signal.aborted) return; + total = finiteNumber(data.total); + renderRows(list, data.items, openDetail); + const pages = Math.max(1, Math.ceil(total / 25)); + pageText.textContent = `${requestedPage} / ${pages}`; + previous.disabled = requestedPage <= 1; + next.disabled = requestedPage >= pages; + status.textContent = total ? `共 ${total} 条反馈` : "没有符合条件的反馈。"; + } catch (reason) { + if (!disposed && !request.signal.aborted) showError(status, reason); + } + }; + + const openDetail = async (eventId) => { + detailRequest.abort(); + detailRequest = new AbortController(); + const request = detailRequest; + detail.innerHTML = "

正在读取详情…

"; + try { + const item = await json(ctx, `/api/dashboard/proactive-feedback/events/${encodeURIComponent(eventId)}`, request.signal); + if (disposed || request.signal.aborted) return; + detail.innerHTML = renderDetail(item); + } catch (reason) { + if (!disposed && !request.signal.aborted) showError(detail, reason); + } + }; + + refresh.addEventListener("click", () => { + void loadOverview(); + void loadList(); + }); + filter.addEventListener("change", () => { + page = 1; + void loadList(); + }); + previous.addEventListener("click", () => { + if (page > 1) { + page -= 1; + void loadList(); + } + }); + next.addEventListener("click", () => { + if (page * 25 < total) { + page += 1; + void loadList(); + } + }); + void loadOverview(); + void loadList(); + return () => { + disposed = true; + overviewRequest.abort(); + listRequest.abort(); + detailRequest.abort(); + host.replaceChildren(); + }; + }, + })); +} + +function renderOverview(data) { + return `
总反馈${finiteNumber(data && data.total)}
话题延续率${percent(data && data.follow_rate)}
明确引用${finiteNumber(data && data.explicit_quote)}
高置信度${finiteNumber(data && data.high_confidence)}
`; +} + +function renderRows(target, items, openDetail) { + target.replaceChildren(); + if (!Array.isArray(items) || !items.length) { + target.innerHTML = "

没有可展示的反馈记录。

"; + return; + } + for (const item of items) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "proactive-feedback-panel-row"; + button.innerHTML = `${escapeHtml(feedbackLabel(item.feedback_type))}${escapeHtml(shortTime(item.created_at))} · ${escapeHtml(confidenceLabel(item.confidence))} · ${escapeHtml(lagText(item.lag_seconds))}${escapeHtml(String(item.user_reply_preview || item.user_preview || "没有可展示的用户反馈"))}`; + button.addEventListener("click", () => void openDetail(item.id)); + target.append(button); + } +} + +function renderDetail(item) { + return `

主动消息效果

用户如何回应这次主动触达

${escapeHtml(String(item.session_key || "未关联会话"))}
${escapeHtml(feedbackLabel(item.feedback_type))}
置信度
${escapeHtml(confidenceLabel(item.confidence))}
响应延迟
${escapeHtml(lagText(item.lag_seconds))}
PUA 分数
${escapeHtml(score(item.pua_score))}

对话链路

${timelineStep("1", "主动消息", item.proactive_preview || item.quoted_preview)}${timelineStep("2", "用户反馈", item.user_reply_preview || item.user_preview, true)}${timelineStep("3", "助手后续", item.assistant_preview)}
查看匹配依据与消息标识
匹配方式
${escapeHtml(String(item.matched_by || "-"))}
用户消息 ID
${escapeHtml(String(item.user_message_id || "-"))}
主动消息 ID
${escapeHtml(String(item.proactive_message_id || "-"))}
`; +} + +function timelineStep(index, title, text, emphasis = false) { + return `
${index}

${title}

${escapeHtml(String(text || "没有可展示的内容"))}

`; +} + +async function json(ctx, path, signal) { + const response = await ctx.http.request(path, {method: "GET", signal}); + const body = await response.json(); + if (!response.ok) throw new Error(body?.detail || body?.message || `HTTP ${response.status}`); + return body; +} + +function feedbackLabel(value) { + return ({explicit_quote: "明确引用", topic_follow: "话题延续", no_topic_follow: "未延续话题", unscored: "待评分"})[value] || String(value || "-"); +} + +function confidenceLabel(value) { + return ({gold: "金标", high: "高", medium: "中", low: "低"})[value] || String(value || "-"); +} + +function score(value) { + const number = Number(value); + return Number.isFinite(number) ? number.toFixed(3) : "-"; +} + +function lagText(value) { + const number = Number(value); + if (!Number.isFinite(number)) return "-"; + if (number < 60) return `${number}s`; + if (number < 3600) return `${Math.round(number / 60)}m`; + return `${(number / 3600).toFixed(1)}h`; +} + +function percent(value) { + const number = Number(value); + return Number.isFinite(number) ? `${Math.round(number * 100)}%` : "-"; +} + +function finiteNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +function shortTime(value) { + const date = new Date(String(value || "")); + return Number.isNaN(date.getTime()) ? "-" : new Intl.DateTimeFormat("zh-CN", {month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false}).format(date); +} + +function showError(target, reason) { + target.setAttribute("role", "alert"); + target.textContent = reason instanceof Error ? reason.message : String(reason); +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (character) => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"})[character]); +}