>(`/api/dashboard/emotion/effects/${item.id}`);
- },
-
- Detail: EmotionDetail,
-
- formatters: {
- score: (value: unknown) => _score(value),
- delta: (value: unknown) => _delta(value),
- "mono-time": (value: unknown) => _shortTs(value),
- },
-});
diff --git a/db.py b/db.py
index 3e20679..2630ced 100644
--- a/db.py
+++ b/db.py
@@ -449,7 +449,7 @@ def prepare_drift_proposal(
# 1. Replay a locally prepared/submitted proposal before admitting new evidence.
existing = conn.execute(
"""
- SELECT proposal_id, revision, payload_json
+ SELECT proposal_id, revision, payload_json, created_at
FROM emotion_drift_runs
WHERE status IN ('prepared', 'submitted')
ORDER BY created_at, proposal_id, revision LIMIT 1
@@ -546,6 +546,7 @@ def prepare_drift_proposal(
"proposal_id": proposal_id,
"revision": revision,
"payload": payload,
+ "due_at": _aware_datetime(now),
}
@@ -722,6 +723,7 @@ def _drift_proposal_from_row(row: sqlite3.Row) -> dict[str, object]:
"proposal_id": str(row["proposal_id"]),
"revision": str(row["revision"]),
"payload": payload,
+ "due_at": _aware_datetime(datetime.fromisoformat(str(row["created_at"]))),
}
diff --git a/plugin.py b/plugin.py
index 2ba01ee..6860524 100644
--- a/plugin.py
+++ b/plugin.py
@@ -32,7 +32,7 @@
api_version = 3
name = "emotion"
-version = "3.0.2"
+version = "3.0.3"
desc = "Timer-refreshed Emotion context and ordinary Drift preference projection."
DRIFT_PROPOSALS = ServiceKey[DriftProposalServices]("drift.proposals.v1")
DRIFT_WAKE = ServiceKey[DriftWakeServices]("drift.wake.v1")
@@ -40,6 +40,12 @@
workspace_roots = ("emotion",)
drift_skill_roots = ("drift/skills",)
dashboard_module = "dashboard.py"
+web_module = "web_module.js"
+web_requires = ("workbench.panels.v1",)
+web_provides = ()
+web_contract_digests = {
+ "workbench.panels.v1": "724b282c22c4b3f3a36967ab664c4dfd8bce4257665f99459000306938caf527",
+}
_v3_emotion_root: Path | None = None
_v3_emotion_runtime: EmotionRuntime | None = None
diff --git a/runtime.py b/runtime.py
index 1aac0c8..ee7ef1a 100644
--- a/runtime.py
+++ b/runtime.py
@@ -112,12 +112,13 @@ async def tick_once(self) -> None:
proposal_id = cast(str, proposal["proposal_id"])
revision = cast(str, proposal["revision"])
payload = cast(Mapping[str, object], proposal["payload"])
+ due_at = cast(datetime, proposal["due_at"])
_ = self._proposals.propose(
proposal_id,
revision,
payload,
- now,
- next_due=now + _REFRESH_INTERVAL,
+ due_at,
+ next_due=due_at + _REFRESH_INTERVAL,
)
db = open_db(self._db_path)
try:
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index e5e31d5..8cb9084 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -38,7 +38,7 @@
)
from agent.plugin_composition.ui_slots import PluginUiSlots
from bus.events_lifecycle import TurnCommitted
-from plugins.drift.store import DriftStore
+from plugins.drift.store import DriftSelectionReceipt, DriftStore
NOW = datetime(2026, 8, 23, 8, tzinfo=UTC)
@@ -96,7 +96,9 @@ def __init__(self, path: Path) -> None:
def propose(self, *args: object, **kwargs: object) -> dict[str, object]:
return self.store.propose(*args, **kwargs) # pyright: ignore[reportArgumentType]
- def selection(self, accepted_turn: dict[str, object]) -> dict[str, object] | None:
+ def selection(
+ self, accepted_turn: dict[str, object]
+ ) -> DriftSelectionReceipt | None:
return self.store.selection(accepted_turn)
@@ -129,6 +131,7 @@ async def _mount_candidate(
inject=module.inject,
runtime=PluginRuntime(
plugin_id="emotion",
+ generation_id=root.generation_id,
plugin_dir=Path(__file__).parents[1],
data_dir=tmp_path / "plugin-data",
workspace=emotion_root.parent,
@@ -168,8 +171,6 @@ def _before_turn(channel: str, at: datetime) -> BeforeTurnCtx:
chat_id="chat",
content="tick",
timestamp=at,
- retrieved_memory_block="",
- retrieval_trace_raw=None,
history_messages=(),
)
@@ -278,6 +279,33 @@ async def test_empty_tick_overwrites_current_without_appending_history(tmp_path:
conn.close()
+@pytest.mark.asyncio
+async def test_submitted_proposal_replay_keeps_original_due_time(tmp_path: Path) -> None:
+ emotion_root = tmp_path / "emotion"
+ module._on_turn_committed(_feedback_turn(), root=emotion_root)
+ drift = DriftServices(tmp_path / "drift.sqlite3")
+ clock = [NOW]
+ runtime = module.EmotionRuntime(
+ cast(Any, object()),
+ emotion_root,
+ PluginTimers.candidate_validation(),
+ drift,
+ drift,
+ now=lambda: clock[0],
+ )
+
+ await runtime.tick_once()
+ clock[0] += timedelta(minutes=5)
+ await runtime.tick_once()
+
+ proposals = cast(
+ tuple[dict[str, Any], ...],
+ drift.store.snapshot(clock[0])["proposals"],
+ )
+ assert len(proposals) == 1
+ assert proposals[0]["due_at"] == NOW.isoformat()
+
+
def _create_formal_legacy_fixture(path: Path) -> None:
"""Create the exact original three-table formal schema without new migration code."""
@@ -805,6 +833,7 @@ async def mount_emotion() -> None:
inject=module.inject,
runtime=PluginRuntime(
plugin_id="emotion",
+ generation_id=root.generation_id,
plugin_dir=Path(__file__).parents[1],
data_dir=tmp_path / "plugin-data" / "emotion",
workspace=emotion_root.parent,
diff --git a/web_module.css b/web_module.css
new file mode 100644
index 0000000..0b82c53
--- /dev/null
+++ b/web_module.css
@@ -0,0 +1,26 @@
+.emotion-workbench-panel { max-width: 1180px; margin-inline: auto; }
+.emotion-workbench-panel > header { display: flex; align-items: end; justify-content: space-between; gap: 20px; }
+.emotion-workbench-panel h1, .emotion-workbench-panel h2, .emotion-workbench-panel h3, .emotion-workbench-panel p { margin: 0; }
+.emotion-workbench-panel > header p, .emotion-panel-row span, .emotion-panel-row small, .emotion-workbench-panel article > header span { color: var(--ak-ink-secondary); }
+.emotion-workbench-panel button { min-height: 44px; }
+.emotion-overview { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; margin-block: 16px; background: var(--ak-rule-subtle); }
+.emotion-overview > div, .emotion-overview > p { margin: 0; padding: 14px; background: var(--ak-paper-quiet); }
+.emotion-overview span, .emotion-overview small { display: block; color: var(--ak-ink-secondary); font-size: 0.78rem; }
+.emotion-overview strong { display: block; margin-block: 4px; }
+.emotion-panel-grid { display: grid; grid-template-columns: minmax(260px, 0.8fr) minmax(340px, 1.2fr); gap: 24px; margin-block-start: 16px; }
+.emotion-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) { .emotion-panel-row:hover { background: var(--ak-paper-quiet); } }
+.emotion-panel-grid footer { display: flex; align-items: center; justify-content: center; gap: 12px; padding-block: 16px; }
+.emotion-panel-grid > article { min-width: 0; border-inline-start: 1px solid var(--ak-rule-subtle); padding-inline-start: 24px; }
+.emotion-panel-grid article > header { display: flex; align-items: start; justify-content: space-between; gap: 16px; }
+.emotion-panel-grid article > header p { color: var(--ak-ink-secondary); font-size: 0.8rem; }
+.emotion-detail-metrics, .emotion-detail-coordinates { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
+.emotion-detail-metrics div, .emotion-detail-coordinates div { padding: 12px; background: var(--ak-paper-quiet); }
+.emotion-detail-metrics dt, .emotion-detail-coordinates dt { color: var(--ak-ink-secondary); font-size: 0.8rem; }
+.emotion-detail-metrics dd, .emotion-detail-coordinates dd { margin: 4px 0 0; font-family: ui-monospace, monospace; }
+.emotion-panel-grid article > section { margin-block: 18px; }
+.emotion-panel-grid details { margin-block-start: 12px; border-block-start: 1px solid var(--ak-rule-subtle); padding-block-start: 12px; }
+.emotion-panel-grid summary { min-height: 44px; cursor: pointer; }
+.emotion-panel-grid pre { overflow: auto; white-space: pre-wrap; }
+.emotion-workbench-panel :focus-visible { outline: 2px solid var(--ak-rule-focus); outline-offset: 2px; }
+@media (max-width: 820px) { .emotion-workbench-panel > header { align-items: start; flex-direction: column; } .emotion-overview, .emotion-detail-metrics, .emotion-detail-coordinates, .emotion-panel-grid { grid-template-columns: 1fr; } .emotion-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..8935bce
--- /dev/null
+++ b/web_module.js
@@ -0,0 +1,175 @@
+export function activate(ctx) {
+ return ctx.ui.inject("workbench.panels.v1", (mount) => mount.register({
+ id: "emotion-decisions",
+ label: "情绪决策",
+ order: 40,
+ render(host) {
+ const panel = document.createElement("section");
+ panel.className = "emotion-workbench-panel";
+ panel.innerHTML = ``;
+ host.replaceChildren(panel);
+ const overview = panel.querySelector("[data-overview]");
+ const refresh = panel.querySelector("[data-refresh]");
+ 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/emotion/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;
+ status.textContent = "正在读取情绪影响…";
+ try {
+ const data = await json(ctx, `/api/dashboard/emotion/effects?page=${requestedPage}&page_size=25`, 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 (effectId) => {
+ detailRequest.abort();
+ detailRequest = new AbortController();
+ const request = detailRequest;
+ detail.innerHTML = "正在读取详情…
";
+ try {
+ const item = await json(ctx, `/api/dashboard/emotion/effects/${encodeURIComponent(effectId)}`, 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();
+ });
+ 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) {
+ const state = data && typeof data.state === "object" && data.state ? data.state : null;
+ const behavior = data && typeof data.current_behavior === "object" && data.current_behavior
+ ? data.current_behavior
+ : null;
+ if (!state || !behavior) {
+ return `还没有可用的情绪状态。已记录 ${finiteNumber(data && data.effect_count)} 条情绪影响。
`;
+ }
+ return `当前语气${escapeHtml(behavior.tone_label || "未标注")}
发送阈值${escapeHtml(effectLabel(behavior.expected_effect))}${escapeHtml(deltaText(behavior.threshold_delta))}
情绪坐标愉悦 ${escapeHtml(score(state.valence))} · 唤醒 ${escapeHtml(score(state.arousal))} · 支配 ${escapeHtml(score(state.dominance))}
`;
+}
+
+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 = "emotion-panel-row";
+ button.innerHTML = `${escapeHtml(effectLabel(item.expected_effect))}${escapeHtml(shortTime(item.created_at))} · ${escapeHtml(String(item.tone_label || "未标注"))}愉悦 ${escapeHtml(score(item.valence))} · 唤醒 ${escapeHtml(score(item.arousal))} · 阈值 ${escapeHtml(deltaText(item.threshold_delta))}`;
+ button.addEventListener("click", () => void openDetail(item.id));
+ target.append(button);
+ }
+}
+
+function renderDetail(item) {
+ const delta = deltaText(item.threshold_delta);
+ return `主动决策输入
这次情绪如何改变发送阈值
${escapeHtml(String(item.tick_id || "未关联任务"))} ${escapeHtml(effectLabel(item.expected_effect))}- 原始阈值
- ${escapeHtml(score(item.base_threshold))}
- 应用情绪后
- ${escapeHtml(score(item.final_threshold))}
- 变化
- ${escapeHtml(delta)}
情绪坐标
语气:${escapeHtml(String(item.tone_label || "未标注"))}
- 愉悦度
- ${escapeHtml(score(item.valence))}
- 唤醒度
- ${escapeHtml(score(item.arousal))}
- 支配度
- ${escapeHtml(score(item.dominance))}
查看写入主动流程的提示词
${escapeHtml(String(item.prompt_section || "-"))}查看技术元数据
${escapeHtml(JSON.stringify(item.metadata || {}, null, 2))} `;
+}
+
+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 effectLabel(value) {
+ return ({raise_send_bar: "提高发送阈值", lower_send_bar: "降低发送阈值"})[value] || String(value || "-");
+}
+
+function score(value) {
+ const number = Number(value);
+ return Number.isFinite(number) ? number.toFixed(3) : "-";
+}
+
+function deltaText(value) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) return "-";
+ return number > 0 ? `+${number.toFixed(3)}` : number.toFixed(3);
+}
+
+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]);
+}