>(`/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]);
+}