Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b3e0a291 | ||
|
|
8895fea775 | ||
|
|
78882f3300 | ||
|
|
988667cd87 | ||
|
|
72968f831f | ||
|
|
af25f51f9e | ||
|
|
b3f13e130f | ||
|
|
a621262fac | ||
|
|
e027635d3f | ||
|
|
4cb33b7108 | ||
|
|
b50c9f33eb | ||
|
|
545b7d38bf | ||
|
|
5f78d67363 | ||
|
|
33d7713d4e | ||
|
|
68a4b546f9 | ||
|
|
cbcc50f35e | ||
|
|
0eb9a2844e | ||
|
|
5103492762 | ||
|
|
082aeee1f2 | ||
|
|
201901d35e |
No files matched your search
@@ -4,6 +4,12 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## MemHub 1.0.5 — 2026-10-28
|
||||
|
||||
Build 36, on `hy-memory==2.0.8`. Linux x64/arm64 Runtime and macOS arm64
|
||||
App and Runtime. Upgrading from 1.0.4 migrates the existing library to SDK
|
||||
authority on first launch.
|
||||
|
||||
- MemHub turns off the HY pipeline log and trace at every diagnostic level
|
||||
(hy-memory 2.0.8 `MEMORY_PIPELINE_LOG_ENABLED=false`,
|
||||
`MEMORY_PIPELINE_TRACE_ENABLED=false`), so the per-step JSONL files under
|
||||
@@ -88,6 +94,27 @@ Hy Memory and MemHub are versioned and released independently.
|
||||
`hy_node_permissions` proof table. Evidence, governed memories, model
|
||||
profiles, SDK stores, and source/host grants are preserved.
|
||||
|
||||
## Hy Memory 2.0.9 — 2026-11-01
|
||||
|
||||
- Placeholder values no longer overwrite the user's basic profile (L0). A
|
||||
small extraction model filled `basic_info` with `{"name": "用户"}`,
|
||||
`The user` or `N/A` when the user had said nothing about themselves, and
|
||||
each placeholder superseded the real value, so the L0 chain read
|
||||
费天翔 → Fei → 用户 → The user. Two defences:
|
||||
- `upsert_basic_profile` drops placeholder values: empty, `用户`,
|
||||
`the user`, `user`, `n/a`, `na`, `none`, `null`, `unknown`, `未提供`,
|
||||
`未知`, `不详`, `-`, `?` and the field's own name, compared
|
||||
case-insensitively with whitespace and punctuation stripped. When nothing
|
||||
is left it writes no new version and no new L0 head. Placeholders already
|
||||
stored in the chain no longer override earlier real values when the
|
||||
profile is assembled, so `get_user_profile` of an affected user shows the
|
||||
last real value again without any migration. This also stops an
|
||||
assistant quoting an old memory ("The user's name is 用户.") from being
|
||||
written back.
|
||||
- The extraction prompts (zh and en) state the rule: only what the user
|
||||
said in a `user` turn, never the field name or a placeholder, `{}`
|
||||
otherwise, with a small-talk counter-example.
|
||||
|
||||
## Hy Memory 2.0.8 — 2026-10-28
|
||||
|
||||
- Published
|
||||
|
||||
@@ -33,6 +33,7 @@ English | [中文](README.zh.md)
|
||||
## Latest News
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** quantized **FP8** (**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**) and **GGUF** (**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**) versions of **Hy-Memory-30B-A3B** on Hugging Face!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)** with Chinese, English and Spanish interfaces, one-time pairing codes for more custom Harnesses, and smoother browsing, search, startup and recovery, on **`hy-memory==2.0.8`**. See the release notes for details.
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **Released [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** and **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**, with a signed macOS desktop app and verified macOS/Linux Runtimes!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Released** **Hy Memory 2.0 (`hy-memory==2.0.4`)** on [PyPI](https://pypi.org/project/hy-memory/2.0.4/) and open-sourced the **Hy-Memory** monorepo together with **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **Open-sourced** **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** on Hugging Face along with its [technical report](https://arxiv.org/abs/2610.22196) on attributed reinforcement learning for long-term memory writing!
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
## 最新动态
|
||||
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **Hy-Memory-30B-A3B** 的 **FP8**(**[Hy-Memory-30B-A3B-FP8](https://huggingface.co/tencent/Hy-Memory-30B-A3B-FP8)**)与 **GGUF**(**[Hy-Memory-30B-A3B-GGUF](https://huggingface.co/tencent/Hy-Memory-30B-A3B-GGUF)**)量化版本模型!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.5](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.5)**:支持中文、英文和西班牙语界面,可通过一次性配对码连接更多自定义 Harness,并优化了记忆浏览、搜索、启动和异常恢复;底层使用 **`hy-memory==2.0.8`**。详见发布说明。
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 📦 **正式发布 [MemHub v1.0.4](https://github.com/Tencent-Hunyuan/Hy-Memory/releases/tag/v1.0.4)** 与 **[memhub-cli 1.0.5](https://www.npmjs.com/package/memhub-cli/v/1.0.5)**,提供已签名的 macOS 桌面应用及经过验证的 macOS/Linux Runtime!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **正式发布** **Hy Memory 2.0 (`hy-memory==2.0.4`)**(已上线 [PyPI](https://pypi.org/project/hy-memory/2.0.4/)),并开源 **Hy-Memory** monorepo 与 **[MemHub](memhub/README.md)**!
|
||||
* *<mark>[new]</mark>* 🔥🔥🔥 **[2026-10]**: 🚀 **模型开源** 在 Hugging Face 发布 **[Hy-Memory-30B-A3B](https://huggingface.co/tencent/Hy-Memory-30B-A3B)** 长期记忆写入模型及[技术报告](https://arxiv.org/abs/2610.22196)!
|
||||
|
||||
@@ -682,6 +682,23 @@ Output:
|
||||
```
|
||||
John's occupation and location are HIS attributes, so they go into `memory` with "John" as the subject. They must NEVER land in `basic_info.occupation` / `basic_info.location` — `basic_info` is exclusively the user's own profile. Only `name` (Tim, from the user message's `name` field) belongs there.
|
||||
|
||||
## Example 11 — Counter-example: small talk has no basic_info
|
||||
|
||||
New Messages:
|
||||
[{"role": "user", "content": "Hi"},
|
||||
{"role": "assistant", "content": "Hello! The user's name is The user. How can I help?"}]
|
||||
Memory Date: 2025-09-03
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"memory": [],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
The user stated nothing about themselves. The assistant's line is not a user turn, and "The user" is a placeholder, not a name. Output `{}` — never `{"name": "The user"}` or `{"name": "user"}`.
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
@@ -749,6 +766,14 @@ Rules:
|
||||
fields (e.g. name, age, location, occupation, employer) **explicitly stated**
|
||||
in the NEW user messages. Never infer, never invent. If no field is explicitly
|
||||
stated, output `{{}}`.
|
||||
* HARD RULE: fill a field ONLY if the user stated it in a `user` turn.
|
||||
Anything said only by the assistant (including the assistant quoting an
|
||||
earlier memory such as "The user's name is X") does NOT count. If nothing
|
||||
qualifies, output `"basic_info": {{}}`.
|
||||
* NEVER write the field name itself or a placeholder as a value ("user",
|
||||
"The user", "用户", "name", "N/A", "unknown", "none", "-"). Omit the field.
|
||||
* Negative example: small talk such as `[user]: Hi` / `[assistant]: How can
|
||||
I help?`, or `[user]: Reply with exactly: PONG` → `"basic_info": {{}}`.
|
||||
* Exception for `name`: the `name` field on the user's own message, or a leading
|
||||
line "The user's name is X" / "用户名叫X", is an authoritative source for
|
||||
`name` — even though the user never said it out loud.
|
||||
@@ -817,6 +842,14 @@ Rules:
|
||||
fields (e.g. name, age, location, occupation, employer) **explicitly stated**
|
||||
in the NEW user messages. Never infer, never invent. If no field is explicitly
|
||||
stated, output `{{}}`.
|
||||
* HARD RULE: fill a field ONLY if the user stated it in a `user` turn.
|
||||
Anything said only by the assistant (including the assistant quoting an
|
||||
earlier memory such as "The user's name is X") does NOT count. If nothing
|
||||
qualifies, output `"basic_info": {{}}`.
|
||||
* NEVER write the field name itself or a placeholder as a value ("user",
|
||||
"The user", "用户", "name", "N/A", "unknown", "none", "-"). Omit the field.
|
||||
* Negative example: small talk such as `[user]: Hi` / `[assistant]: How can
|
||||
I help?`, or `[user]: Reply with exactly: PONG` → `"basic_info": {{}}`.
|
||||
* Exception for `name`: the `name` field on the user's own message, or a leading
|
||||
line "The user's name is X" / "用户名叫X", is an authoritative source for
|
||||
`name` — even though the user never said it out loud.
|
||||
|
||||
@@ -365,6 +365,13 @@ EXTRACT_PROMPT_ZH = """## 最近 k 条消息
|
||||
* basic_info:**只装用户本人的画像**。
|
||||
仅当 NEW user messages 中**明确**陈述稳定画像字段(如 姓名、年龄、所在地、职业、雇主)时才填写。
|
||||
绝不推断、绝不编造。没有任何字段被明确陈述时输出 `{{}}`。
|
||||
* **硬性规则**:只有用户在 `user` 回合里**明确说出**的字段才能填写。只在
|
||||
assistant 回合出现的内容(包括 assistant 引用旧记忆,如"The user's name is X"
|
||||
/ "用户名是X")一律不算。没有符合条件的字段时,必须输出 `"basic_info": {{}}`。
|
||||
* **绝不**把字段名本身或占位符当值写入("用户"、"The user"、"user"、"name"、
|
||||
"姓名"、"N/A"、"unknown"、"未知"、"未提供"、"-"),宁可省略该字段。
|
||||
* 负例:寒暄如 `[user]: 你好` / `[assistant]: 我可以帮你。`,或
|
||||
`[user]: Reply with exactly: PONG` → `"basic_info": {{}}`。
|
||||
* `name` 的例外:用户自己那条消息上的 `name` 字段,或文首形如
|
||||
"用户名叫X" / "The user's name is X" 的那一行,都是 `name` 的权威来源
|
||||
——即使用户本人从未开口自报姓名。
|
||||
@@ -440,6 +447,13 @@ EXTRACT_PROMPT_ZH_NO_INTENTION = """## 最近 k 条消息
|
||||
* basic_info:**只装用户本人的画像**。
|
||||
仅当 NEW user messages 中**明确**陈述稳定画像字段(如 姓名、年龄、所在地、职业、雇主)时才填写。
|
||||
绝不推断、绝不编造。没有任何字段被明确陈述时输出 `{{}}`。
|
||||
* **硬性规则**:只有用户在 `user` 回合里**明确说出**的字段才能填写。只在
|
||||
assistant 回合出现的内容(包括 assistant 引用旧记忆,如"The user's name is X"
|
||||
/ "用户名是X")一律不算。没有符合条件的字段时,必须输出 `"basic_info": {{}}`。
|
||||
* **绝不**把字段名本身或占位符当值写入("用户"、"The user"、"user"、"name"、
|
||||
"姓名"、"N/A"、"unknown"、"未知"、"未提供"、"-"),宁可省略该字段。
|
||||
* 负例:寒暄如 `[user]: 你好` / `[assistant]: 我可以帮你。`,或
|
||||
`[user]: Reply with exactly: PONG` → `"basic_info": {{}}`。
|
||||
* `name` 的例外:用户自己那条消息上的 `name` 字段,或文首形如
|
||||
"用户名叫X" / "The user's name is X" 的那一行,都是 `name` 的权威来源
|
||||
——即使用户本人从未开口自报姓名。
|
||||
@@ -669,6 +683,23 @@ Memory Date:2025-09-02
|
||||
```
|
||||
职业和所在地是 **John 的**属性,所以进 `memory` 并以"John"作主体,**绝不能**落到 `basic_info` 的 `occupation` / `location` —— `basic_info` 专属于用户本人。只有 `name`(Tim,来自用户消息的 `name` 字段)才该放进去。注意输出语言随输入语言(此例为英文)。
|
||||
|
||||
## 示例 11 —— 反例:寒暄没有 basic_info
|
||||
|
||||
新消息:
|
||||
[{"role": "user", "content": "你好"},
|
||||
{"role": "assistant", "content": "你好!我可以帮你。之前记得 The user's name is 用户."}]
|
||||
Memory Date:2025-09-03
|
||||
|
||||
输出:
|
||||
```json
|
||||
{
|
||||
"memory": [],
|
||||
"intentions": [],
|
||||
"basic_info": {}
|
||||
}
|
||||
```
|
||||
用户没有说任何关于自己的信息。assistant 那句不是 user 回合,"用户" 也是占位符而不是名字。必须输出 `{}`——**绝不能**输出 `{"name": "用户"}`。
|
||||
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
@@ -208,6 +210,76 @@ def _sanitize_kv(
|
||||
return result
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 占位符守卫(P0-1)
|
||||
# ================================================================
|
||||
# 弱模型(如 qwen3.5-2b)在用户什么都没说时,会把 schema 字段说明里的词当值
|
||||
# 填进 basic_info:{"name": "用户"} / "The user" / "N/A"。每个占位符都会
|
||||
# supersede 掉真实值,演化链变成 费天翔 → Fei → 用户 → The user。
|
||||
# 规则:占位符永远不入库;读侧组装 KV 时占位符也不覆盖已有真实值。
|
||||
|
||||
_PLACEHOLDER_VALUES = frozenset({
|
||||
"用户", "该用户", "本用户", "用户本人",
|
||||
"the user", "the user's", "user", "a user", "this user",
|
||||
"n/a", "na", "none", "null", "nil", "unknown", "unspecified",
|
||||
"not provided", "not specified", "not mentioned",
|
||||
"未提供", "未知", "不详", "未说明", "未提及", "无",
|
||||
"-", "?",
|
||||
})
|
||||
|
||||
# 内置字段的中文标签:弱模型也会把字段说明的中文词当值。
|
||||
_FIELD_LABELS: Dict[str, tuple] = {
|
||||
"name": ("姓名", "名字", "用户名", "用户姓名", "用户名字"),
|
||||
"age": ("年龄", "用户年龄"),
|
||||
"location": ("所在地", "地点", "城市", "用户所在地"),
|
||||
"occupation": ("职业", "工作", "用户职业"),
|
||||
"employer": ("雇主", "公司", "用户雇主"),
|
||||
}
|
||||
|
||||
# 去掉标点/符号后再比较,让 "The user." / "用户。" / "N/A" 都命中。
|
||||
# "-" / "?" 本身就是纯标点,剥完为空串,同样判为占位符。
|
||||
_PUNCT_RE = re.compile(r"[\s\.,;:!?'\"`~()\[\]{}<>/\\|_\-\u3000-\u303f\uff00-\uff0f\uff1a-\uff20]+")
|
||||
|
||||
|
||||
def _placeholder_key(value: str) -> str:
|
||||
s = unicodedata.normalize("NFKC", str(value or "")).strip().lower()
|
||||
s = s.replace("\u2019", "'")
|
||||
s = _PUNCT_RE.sub(" ", s)
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
_PLACEHOLDER_KEYS = frozenset(_placeholder_key(v) for v in _PLACEHOLDER_VALUES)
|
||||
|
||||
|
||||
def is_placeholder_value(field_name: str, value: Any) -> bool:
|
||||
"""value 是否为占位符(空 / 用户 / The user / N/A / 字段名本身 等)。
|
||||
|
||||
大小写不敏感、首尾空白与标点不敏感。数字(如 age=30)永远不是占位符。
|
||||
"""
|
||||
if value is None:
|
||||
return True
|
||||
if isinstance(value, bool):
|
||||
return False
|
||||
if isinstance(value, (int, float)):
|
||||
return False
|
||||
key = _placeholder_key(value)
|
||||
if not key:
|
||||
return True
|
||||
if key in _PLACEHOLDER_KEYS:
|
||||
return True
|
||||
field_key = _placeholder_key(field_name)
|
||||
if field_key and key in (field_key, field_key.replace("_", " ")):
|
||||
return True
|
||||
for label in _FIELD_LABELS.get(field_name, ()):
|
||||
if key == _placeholder_key(label):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _drop_placeholders(kv: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {k: v for k, v in kv.items() if not is_placeholder_value(k, v)}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 公共入口:upsert_basic_profile
|
||||
# ================================================================
|
||||
@@ -287,6 +359,8 @@ async def _upsert_basic_profile_unlocked(
|
||||
# L0 Basic Info 从输入、唯一性到落盘都只属于 user_id。
|
||||
|
||||
new_kv = _sanitize_kv(kv or {}, allowed_fields=allowed_fields)
|
||||
# 单调性守卫:占位符永远不覆盖已有值,过滤后为空就不写新版本、不产生新链头。
|
||||
new_kv = _drop_placeholders(new_kv)
|
||||
if not new_kv:
|
||||
return BasicProfileUpsertResult(
|
||||
success=True, changed=False, reason="no valid fields after sanitize"
|
||||
@@ -513,5 +587,8 @@ def _assemble_full_kv_from_nodes(
|
||||
for k, v in kv.items():
|
||||
if allowed_set is not None and k not in allowed_set:
|
||||
continue
|
||||
# 历史上已落盘的占位符版本(如 name=用户)不覆盖更早的真实值。
|
||||
if is_placeholder_value(k, v):
|
||||
continue
|
||||
full_kv[k] = v
|
||||
return full_kv
|
||||
+2
-2
@@ -19,12 +19,12 @@ If you run into a problem, please open an Issue with the version, platform,repro
|
||||
## 发行与平台合同
|
||||
|
||||
源码当前固定使用公开 PyPI 正式版 `hy-memory==2.0.8`。MemHub 源码版本为 `1.0.5`
|
||||
(build 14),GitHub 仓库为
|
||||
(build 36),GitHub 仓库为
|
||||
[`Tencent-Hunyuan/Hy-Memory`](https://github.com/Tencent-Hunyuan/Hy-Memory),npm 包名为
|
||||
`memhub-cli`。当前代码架构见
|
||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)。
|
||||
|
||||
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64/x64 App 和 Runtime。
|
||||
`v1.0.5` 的发行目标是 Linux x64/arm64 Runtime 与 macOS arm64 App 和 Runtime。
|
||||
Linux 制品由 GitHub Actions 在原生架构 runner 上构建并完成生命周期验收;macOS
|
||||
制品由发行流水线完成 Developer ID 签名、公证和验收后手动上传。Windows 不支持。
|
||||
只有同时出现在 GitHub Release 与 `release-manifest.json` 中、且通过公开校验的制品才属于
|
||||
|
||||
@@ -2980,11 +2980,13 @@ def create_app(
|
||||
"""
|
||||
try:
|
||||
client = await _sdk_client_handle(app).client()
|
||||
# The map draws the page the list shows, so it reads the same
|
||||
# source filter and window rather than the newest memories overall.
|
||||
# The map visualizes the active source scope, independently of the
|
||||
# left list's 20-row pagination window.
|
||||
raw = await asyncio.to_thread(
|
||||
client.list_memories,
|
||||
user_id="local",
|
||||
scene="all",
|
||||
statuses=["active"],
|
||||
include_sources=[source] if source else None,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
|
||||
@@ -1375,6 +1375,8 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
|
||||
|
||||
graph = asyncio.run(scenario())
|
||||
assert listed[0]["include_sources"] == ["cursor"]
|
||||
assert listed[0]["scene"] == "all"
|
||||
assert listed[0]["statuses"] == ["active"]
|
||||
assert (listed[0]["offset"], listed[0]["limit"]) == (100, 100)
|
||||
assert (graph["total_memories"], graph["projected_memories"]) == (7, 4)
|
||||
assert {node["node_id"] for node in graph["nodes"]} == {
|
||||
@@ -1399,6 +1401,71 @@ def test_the_sdk_memory_map_draws_sdk_memories_without_legacy_tables(tmp_path) -
|
||||
assert graph["hy_graph_available"] is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.fresh_sdk
|
||||
def test_sdk_memory_map_still_draws_memories_from_a_revoked_source(tmp_path) -> None:
|
||||
# Revoking a source stops new reads from it; memories already distilled
|
||||
# from it stay visible, so the map agrees with the list.
|
||||
async def scenario():
|
||||
app = create_app(_app_settings(tmp_path), secret_store=InMemorySecretStore())
|
||||
|
||||
class Client:
|
||||
def list_memories(self, **_kwargs):
|
||||
return {
|
||||
"vdb": {
|
||||
"memories": [
|
||||
{
|
||||
"memory_id": "kept",
|
||||
"content": "distilled before revocation",
|
||||
"layer": "l2_fact",
|
||||
"status": "active",
|
||||
"user_id": "local",
|
||||
"agent_id": "all-apps",
|
||||
"session_id": "s1",
|
||||
"source": "cursor",
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
}
|
||||
}
|
||||
|
||||
class Handle:
|
||||
async def client(self):
|
||||
return Client()
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": "Bearer authority-test"},
|
||||
) as client,
|
||||
):
|
||||
app.state.sdk_client_handle = Handle()
|
||||
database = app.state.settings.data_dir / "one-memory.sqlite3"
|
||||
with sqlite3.connect(database) as db:
|
||||
db.execute(
|
||||
"INSERT INTO source_grants "
|
||||
"(user_id,source_id,root_path,requested_path,root_identity,"
|
||||
"candidate_kind,granted_at,revoked_at,revision) VALUES "
|
||||
"('local','cursor','/fixture','/fixture','fixture','default',"
|
||||
"'2026-10-01T00:00:00Z','2026-10-02T00:00:00Z',1)"
|
||||
)
|
||||
response = await client.get(
|
||||
"/api/v2/memories/graph",
|
||||
params={"source": "cursor", "limit": 100},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
graph = asyncio.run(scenario())
|
||||
assert (graph["total_memories"], graph["projected_memories"]) == (1, 1)
|
||||
assert "memory:kept" in {node["node_id"] for node in graph["nodes"]}
|
||||
|
||||
|
||||
def test_a_completed_sdk_write_does_not_touch_retired_evidence(store) -> None:
|
||||
"""Build 21 logged RetiredAuthorityAccess after every successful write."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -172,7 +172,7 @@ it.each([
|
||||
expect(sedimentation).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
it("localizes failure and empty states without leaking errors", async () => {
|
||||
it("localizes startup recovery and empty states without leaking errors", async () => {
|
||||
summary.mockRejectedValue(new Error("synthetic_private_error"));
|
||||
Object.assign(window.oneMemory ?? {}, {
|
||||
getLanguagePreference: vi
|
||||
@@ -191,12 +191,15 @@ it("localizes failure and empty states without leaking errors", async () => {
|
||||
</LanguageProvider>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("No se puede leer el estado de las memorias"),
|
||||
await screen.findByText(
|
||||
"El servicio se está iniciando; se actualizará automáticamente",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("Aún no hay aplicaciones para mostrar")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Reintentar lectura" }),
|
||||
).toBeTruthy();
|
||||
screen.queryByRole("button", { name: "Reintentar lectura" }),
|
||||
).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("synthetic_private_error");
|
||||
});
|
||||
it("shows latest recorded call separately without exposing recent-update detail entries", async () => {
|
||||
@@ -765,7 +768,7 @@ it("keeps authoritative home and processing data when only the source snapshot b
|
||||
expect(screen.getAllByText("整理中")).toHaveLength(2);
|
||||
expect(screen.queryByText("状态待核对")).toBeNull();
|
||||
});
|
||||
it("does not turn a failed home summary into zero or expose a private error", async () => {
|
||||
it("keeps a neutral zero while the first home summary read recovers", async () => {
|
||||
summary.mockRejectedValue(new Error("private offline detail"));
|
||||
render(
|
||||
<ApplicationsHome
|
||||
@@ -775,10 +778,117 @@ it("does not turn a failed home summary into zero or expose a private error", as
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("暂无法读取")).toBeTruthy());
|
||||
expect(screen.queryByText("0")).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("正在准备记忆概览")).toBeTruthy();
|
||||
expect(screen.queryByText(/暂时?无法读取|读取失败/)).toBeNull();
|
||||
expect(document.body.textContent).not.toContain("private offline detail");
|
||||
});
|
||||
it("offers a calm retry after repeated home summary failures", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
summary.mockRejectedValue(new Error("private offline detail"));
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("后台恢复后会自动更新,也可以重新读取。"),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "重新读取" })).toBeTruthy();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("counts repeated stale summaries and recovers on the next confirmed read", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const stale = {
|
||||
memory_count: 0,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
stale: true,
|
||||
};
|
||||
summary
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValue({
|
||||
memory_count: 4,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:01:00Z",
|
||||
});
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("后台正在启动,稍后自动更新")).toBeTruthy(),
|
||||
);
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(await screen.findByText("记忆概览还没准备好")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "重新读取" }));
|
||||
expect(await screen.findByText("4")).toBeTruthy();
|
||||
expect(screen.queryByText("记忆概览还没准备好")).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("labels a retained count as previous data after repeated stale reads", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const stale = {
|
||||
memory_count: 0,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
stale: true,
|
||||
};
|
||||
summary
|
||||
.mockResolvedValueOnce({
|
||||
memory_count: 601,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:00:00Z",
|
||||
})
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValueOnce(stale)
|
||||
.mockResolvedValue({
|
||||
memory_count: 4,
|
||||
latest: null,
|
||||
observed_at: "2026-09-19T00:01:00Z",
|
||||
});
|
||||
render(
|
||||
<ApplicationsHome
|
||||
imports={imports}
|
||||
onManage={() => {}}
|
||||
onMemories={() => {}}
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByText("601")).toBeTruthy();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(20_000));
|
||||
expect(screen.getByText("601")).toBeTruthy();
|
||||
expect(screen.getByText("正在显示上次数据,稍后自动更新")).toBeTruthy();
|
||||
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
||||
expect(await screen.findByText("4")).toBeTruthy();
|
||||
expect(screen.queryByText("正在显示上次数据,稍后自动更新")).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
it("never calls a recall event a success when only stored memories are available", async () => {
|
||||
render(
|
||||
<ApplicationsHome
|
||||
@@ -1200,7 +1310,7 @@ it("keeps the conversation-sync checking copy before the first home read", () =>
|
||||
expect(screen.queryByText(/个应用已开启/)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not claim there are no memories while the first summary read is pending", async () => {
|
||||
it("shows a provisional zero while the first summary read is pending", async () => {
|
||||
summary.mockImplementation(() => new Promise(() => {}));
|
||||
history.mockResolvedValue({
|
||||
available: true,
|
||||
@@ -1216,7 +1326,9 @@ it("does not claim there are no memories while the first summary read is pending
|
||||
onSettings={() => {}}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("正在读取动态");
|
||||
await screen.findByText("正在准备记忆概览");
|
||||
expect(screen.getByText("0")).toBeTruthy();
|
||||
expect(screen.getByText("正在核对本机记忆…")).toBeTruthy();
|
||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ export function ApplicationsHome({
|
||||
null,
|
||||
);
|
||||
const [syncHealth, setSyncHealth] = useState<LiveSyncHealth[]>([]);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [summaryFailures, setSummaryFailures] = useState(0);
|
||||
const [recentCall, setRecentCall] = useState<CallEvent | null>(null);
|
||||
const [callEvents, setCallEvents] = useState<CallEvent[]>([]);
|
||||
const [callState, setCallState] = useState<
|
||||
@@ -554,13 +554,17 @@ export function ApplicationsHome({
|
||||
busy = false;
|
||||
if (stopped) return;
|
||||
if (home.status === "fulfilled") {
|
||||
setFailed(false);
|
||||
// stale=true 表示这轮读取超时,数字不代表现状:保留上一次的概览,
|
||||
// 等 10 秒后的下一轮轮询补上,绝不清零也不报错。
|
||||
if (!("stale" in home.value) || !home.value.stale)
|
||||
// 等 10 秒后的下一轮轮询补上。重复 stale 仍要进入可恢复状态,
|
||||
// 不能永远停在“正在核对”。
|
||||
if (!("stale" in home.value) || !home.value.stale) {
|
||||
setSummaryFailures(0);
|
||||
setSummary(home.value);
|
||||
} else {
|
||||
setSummaryFailures((count) => count + 1);
|
||||
}
|
||||
} else {
|
||||
setFailed(true);
|
||||
setSummaryFailures((count) => count + 1);
|
||||
}
|
||||
if (state.status === "fulfilled") setProcessing(state.value);
|
||||
if (sync.status === "fulfilled") setSyncHealth(sync.value);
|
||||
@@ -578,7 +582,8 @@ export function ApplicationsHome({
|
||||
}, [active, requestKey]);
|
||||
const visible = summary;
|
||||
const latest = visible?.latest;
|
||||
const summaryUnavailable = failed;
|
||||
const summaryUnavailable = visible === null && summaryFailures >= 2;
|
||||
const summaryStale = visible !== null && summaryFailures >= 2;
|
||||
const hasCall =
|
||||
recentCall !== null &&
|
||||
(callState === "ready" || callState === "incomplete");
|
||||
@@ -642,7 +647,7 @@ export function ApplicationsHome({
|
||||
? t("apps.running")
|
||||
: sedimentation?.enabled === false
|
||||
? t("apps.paused")
|
||||
: t("apps.unchecked");
|
||||
: t("apps.checkingStatus");
|
||||
const stateDetail =
|
||||
sedimentation?.enabled === false
|
||||
? t("apps.stoppedNew")
|
||||
@@ -821,42 +826,27 @@ export function ApplicationsHome({
|
||||
<h2>{t("apps.overview")}</h2>
|
||||
</header>
|
||||
<div className="r6-home-metrics">
|
||||
<div>
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<p>{t("apps.memories")}</p>
|
||||
{/* The figure slot used to hold the word 读取中 while the first
|
||||
read was in flight, which is how "大数字不可见" (D51) shows up
|
||||
on a slow link: the biggest type on the page spends the first
|
||||
frame saying something that is not a number. A bar of the same
|
||||
height reserves the digits instead, and the wording moves to a
|
||||
live region so screen readers still hear it. 暂无法读取 stays a
|
||||
word, because that is a settled state rather than progress. */}
|
||||
{!visible && !summaryUnavailable ? (
|
||||
<>
|
||||
<p className="r6-home-figure">
|
||||
<span
|
||||
className="r6-home-figure-pending"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</p>
|
||||
<small role="status">{t("apps.readingCount")}</small>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="r6-home-figure">
|
||||
<strong className={visible ? "" : "r6-home-word"}>
|
||||
{visible
|
||||
? new Intl.NumberFormat(locale).format(
|
||||
visible.memory_count,
|
||||
)
|
||||
: t("apps.unreadable")}
|
||||
</strong>
|
||||
{visible && t("apps.unit") ? (
|
||||
<i>{t("apps.unit")}</i>
|
||||
) : null}
|
||||
</p>
|
||||
<small>{t("apps.scope")}</small>
|
||||
</>
|
||||
)}
|
||||
<p className="r6-home-figure">
|
||||
<strong>
|
||||
{new Intl.NumberFormat(locale).format(
|
||||
visible?.memory_count ?? 0,
|
||||
)}
|
||||
</strong>
|
||||
{t("apps.unit") ? <i>{t("apps.unit")}</i> : null}
|
||||
</p>
|
||||
<small>
|
||||
{visible
|
||||
? summaryStale
|
||||
? t("apps.showingPrevious")
|
||||
: t("apps.scope")
|
||||
: summaryUnavailable
|
||||
? t("apps.countWillUpdate")
|
||||
: summaryFailures
|
||||
? t("apps.startingRetry")
|
||||
: t("apps.readingCount")}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<p>{t("apps.processing")}</p>
|
||||
@@ -867,12 +857,16 @@ export function ApplicationsHome({
|
||||
<p>{t("apps.recentUpdate")}</p>
|
||||
<strong className={time ? "r6-home-time" : "r6-home-word"}>
|
||||
{time ??
|
||||
(visible ? t("apps.notGenerated") : t("apps.unchecked"))}
|
||||
(visible
|
||||
? t("apps.notGenerated")
|
||||
: t("apps.checkingRecent"))}
|
||||
</strong>
|
||||
<small>
|
||||
{time
|
||||
? `${latestClock?.today ? `${t("apps.today")}· ` : ""}${t("apps.saved")}`
|
||||
: t("apps.separate")}
|
||||
: visible
|
||||
? t("apps.separate")
|
||||
: t("apps.recentWillUpdate")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -972,7 +966,9 @@ export function ApplicationsHome({
|
||||
.join(" · ")
|
||||
: activity === "importing"
|
||||
? t("apps.savingConversations")
|
||||
: t("apps.waitingMemories")}
|
||||
: activity === "loading"
|
||||
? t("apps.recentWillUpdate")
|
||||
: t("apps.waitingMemories")}
|
||||
</small>
|
||||
</section>
|
||||
{(activity === "unavailable" ||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { translate } from "@one-memory/i18n";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { LanguageSelector } from "./LanguageSelector";
|
||||
import { optionMenuPosition } from "./OptionMenu";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -159,3 +166,141 @@ it("announces storage errors without replacing the confirmed choice", async () =
|
||||
"zh-CN",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a right-edge menu inside the viewport", () => {
|
||||
expect(
|
||||
optionMenuPosition(
|
||||
{ top: 8, right: 1016, bottom: 40, left: 872, width: 144 },
|
||||
160,
|
||||
1024,
|
||||
600,
|
||||
),
|
||||
).toMatchObject({
|
||||
top: 44,
|
||||
left: 868,
|
||||
width: 144,
|
||||
maxHeight: 280,
|
||||
placement: "below",
|
||||
});
|
||||
});
|
||||
|
||||
it("flips the menu above when the lower edge has no room", () => {
|
||||
expect(
|
||||
optionMenuPosition(
|
||||
{ top: 540, right: 500, bottom: 572, left: 356, width: 144 },
|
||||
180,
|
||||
1024,
|
||||
600,
|
||||
),
|
||||
).toMatchObject({
|
||||
top: 356,
|
||||
left: 356,
|
||||
width: 144,
|
||||
maxHeight: 280,
|
||||
placement: "above",
|
||||
});
|
||||
});
|
||||
|
||||
it("limits the panel to the actual space when neither side has 280px", () => {
|
||||
expect(
|
||||
optionMenuPosition(
|
||||
{ top: 40, right: 160, bottom: 60, left: 16, width: 144 },
|
||||
180,
|
||||
320,
|
||||
100,
|
||||
),
|
||||
).toMatchObject({
|
||||
top: 64,
|
||||
left: 16,
|
||||
width: 144,
|
||||
maxHeight: 24,
|
||||
placement: "below",
|
||||
});
|
||||
});
|
||||
|
||||
it("portals the open menu outside clipping layout containers", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<div data-testid="clipping-parent">
|
||||
<LanguageSelector compact />
|
||||
</div>
|
||||
</LanguageProvider>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
const listbox = screen.getByRole("listbox");
|
||||
expect(listbox.parentElement).toBe(document.body);
|
||||
expect(
|
||||
listbox.classList.contains("r6-language-option-menu-panel--compact"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the menu open while its portal scrollbar or padding is pressed", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
await userEvent.click(selector);
|
||||
const listbox = screen.getByRole("listbox");
|
||||
fireEvent.mouseDown(listbox);
|
||||
fireEvent.blur(selector, { relatedTarget: null });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("true");
|
||||
fireEvent.mouseUp(document);
|
||||
});
|
||||
|
||||
it("clears the portal pointer guard when the menu closes mid-press", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
await userEvent.click(selector);
|
||||
fireEvent.mouseDown(screen.getByRole("listbox"));
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
await userEvent.click(selector);
|
||||
fireEvent.blur(selector, { relatedTarget: null });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("clears the portal pointer guard when the window loses focus", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
await userEvent.click(selector);
|
||||
fireEvent.mouseDown(screen.getByRole("listbox"));
|
||||
fireEvent.blur(window);
|
||||
fireEvent.blur(selector, { relatedTarget: null });
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("closes the portal when its anchor scrolls completely out of view", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector compact />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
vi.spyOn(selector, "getBoundingClientRect").mockReturnValue({
|
||||
top: 20,
|
||||
right: 1168,
|
||||
bottom: 52,
|
||||
left: 1024,
|
||||
width: 144,
|
||||
height: 32,
|
||||
x: 1024,
|
||||
y: 20,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
await userEvent.click(selector);
|
||||
await waitFor(() =>
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false"),
|
||||
);
|
||||
expect(screen.queryByRole("listbox")).toBeNull();
|
||||
});
|
||||
@@ -54,6 +54,11 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
disabled={pending}
|
||||
options={options}
|
||||
className="r6-language-option-menu"
|
||||
panelClassName={
|
||||
compact
|
||||
? "r6-language-option-menu-panel--compact"
|
||||
: "r6-language-option-menu-panel--settings"
|
||||
}
|
||||
onChange={async (value) => {
|
||||
if (!isLanguagePreference(value)) return;
|
||||
setPending(true);
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -259,6 +258,40 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
|
||||
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
|
||||
});
|
||||
|
||||
it("shows a stable loading surface when the selected memory is read again", async () => {
|
||||
const view = render(<Host simplified />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
view.rerender(<Host simplified deferMemoryId="m0" />);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
"button",
|
||||
{ name: /第一条/ },
|
||||
),
|
||||
);
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
|
||||
expect(document.querySelector(".map-detail-loading")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows retry instead of stale content when the selected memory reread fails", async () => {
|
||||
const view = render(<Host simplified />);
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
view.rerender(<Host simplified failMemoryId="m0" />);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
"button",
|
||||
{ name: /第一条/ },
|
||||
),
|
||||
);
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"这条记忆的原文暂时无法读取",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "重试读取原文" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the detail header outside the scrolling body and resets detail scroll on selection", async () => {
|
||||
const { container } = render(
|
||||
<Host simplified sources={[importedSources[0]]} />,
|
||||
@@ -269,6 +302,14 @@ it("keeps the detail header outside the scrolling body and resets detail scroll
|
||||
const body = container.querySelector<HTMLElement>(".map-inspector-body");
|
||||
expect(heading?.nextElementSibling).toBe(body);
|
||||
if (!body) throw new Error("Memory detail scroll body is missing");
|
||||
body.scrollTop = 90;
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
"button",
|
||||
{ name: /第一条/ },
|
||||
),
|
||||
);
|
||||
expect(body.scrollTop).toBe(0);
|
||||
body.scrollTop = 180;
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
@@ -348,6 +389,8 @@ function Host({
|
||||
processing = null,
|
||||
loaded = true,
|
||||
deferMemoryId,
|
||||
failMemoryId,
|
||||
graphValue = graph,
|
||||
onViewHistory,
|
||||
onConfigureModel = () => {},
|
||||
libraryView,
|
||||
@@ -363,11 +406,17 @@ function Host({
|
||||
processing?: MemoryProcessingSummary | null;
|
||||
loaded?: boolean;
|
||||
deferMemoryId?: string;
|
||||
failMemoryId?: string;
|
||||
graphValue?: MemoryGraphResponse;
|
||||
onViewHistory?(): void;
|
||||
onConfigureModel?(): void;
|
||||
libraryView?: Parameters<typeof MemoryMap>[0]["libraryView"];
|
||||
} = {}) {
|
||||
const [detail, setDetail] = useState<MemoryDetail | null>(null);
|
||||
const [detailLoad, setDetailLoad] = useState<{
|
||||
memoryId: string;
|
||||
status: "loading" | "failed";
|
||||
} | null>(null);
|
||||
return (
|
||||
<MemoryMap
|
||||
simplified={simplified}
|
||||
@@ -375,7 +424,7 @@ function Host({
|
||||
hidden={hidden}
|
||||
overlayBlocked={overlayBlocked}
|
||||
memories={items}
|
||||
graph={graph}
|
||||
graph={graphValue}
|
||||
sources={sources}
|
||||
arrival={arrival}
|
||||
onViewHistory={onViewHistory}
|
||||
@@ -389,14 +438,25 @@ function Host({
|
||||
}
|
||||
: null
|
||||
}
|
||||
detailLoad={detailLoad}
|
||||
draft={detail?.memory.content ?? ""}
|
||||
busy={false}
|
||||
answer={null}
|
||||
streaming={null}
|
||||
requestIds={{ current: new Map() }}
|
||||
onOpenMemory={(id) => {
|
||||
if (failMemoryId && id === failMemoryId) {
|
||||
setDetail(null);
|
||||
setDetailLoad({ memoryId: id, status: "failed" });
|
||||
return;
|
||||
}
|
||||
// Lets a test leave one read unanswered.
|
||||
if (deferMemoryId && id === deferMemoryId) return;
|
||||
if (deferMemoryId && id === deferMemoryId) {
|
||||
setDetail(null);
|
||||
setDetailLoad({ memoryId: id, status: "loading" });
|
||||
return;
|
||||
}
|
||||
setDetailLoad(null);
|
||||
setDetail({
|
||||
memory: items.find((m) => m.memory_id === id) ?? items[0],
|
||||
evidence: [
|
||||
@@ -414,7 +474,10 @@ function Host({
|
||||
index_sync: null,
|
||||
});
|
||||
}}
|
||||
onCloseDetail={() => setDetail(null)}
|
||||
onCloseDetail={() => {
|
||||
setDetail(null);
|
||||
setDetailLoad(null);
|
||||
}}
|
||||
onAsk={() => {}}
|
||||
onDraft={() => {}}
|
||||
onSave={() => {}}
|
||||
@@ -467,6 +530,57 @@ it("does not present an unverified empty page as zero memories", () => {
|
||||
expect(screen.queryByText(/0 条/)).toBeNull();
|
||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show the previous source graph while the new scope's graph loads", () => {
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
items: memories,
|
||||
total: memories.length,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
}),
|
||||
query: "项目",
|
||||
graphLoading: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(screen.getByText("正在读取记忆地图…")).toBeTruthy();
|
||||
// The list keeps its own state: graph loading is not page staleness.
|
||||
expect(screen.queryByText("正在核对来源…")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a retry instead of collapsing a failed graph to the current page", async () => {
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
items: memories,
|
||||
total: memories.length,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
}),
|
||||
graphFailed: true,
|
||||
onRetry,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId("canvas")).toBeNull();
|
||||
expect(screen.getByText("记忆暂时无法读取")).toBeTruthy();
|
||||
expect(screen.getByRole("status").textContent).toBe("记忆暂时无法读取");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "重新读取地图记忆" }),
|
||||
);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("starts with receipt-backed source grouping, keeping topics secondary", () => {
|
||||
render(<Host />);
|
||||
expect(
|
||||
@@ -478,6 +592,118 @@ it("starts with receipt-backed source grouping, keeping topics secondary", () =>
|
||||
expect(screen.getByRole("button", { name: "Codex2" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("restores the full graph scope when the source filter returns to all applications", async () => {
|
||||
const workbuddyMemory = {
|
||||
...memories[1],
|
||||
memory_id: "workbuddy-memory",
|
||||
content: "较早的 WorkBuddy 记忆",
|
||||
};
|
||||
const allGraph = {
|
||||
total_memories: 2,
|
||||
projected_memories: 2,
|
||||
hy_graph_available: false,
|
||||
nodes: [
|
||||
graph.nodes.find((node) => node.node_id === "memory:m0"),
|
||||
graph.nodes.find((node) => node.node_id === "source:codex"),
|
||||
{
|
||||
node_id: "memory:workbuddy-memory",
|
||||
node_type: "memory",
|
||||
memory_id: "workbuddy-memory",
|
||||
label: workbuddyMemory.content,
|
||||
},
|
||||
{
|
||||
node_id: "source:workbuddy",
|
||||
node_type: "source",
|
||||
source_id: "workbuddy",
|
||||
label: "WorkBuddy",
|
||||
},
|
||||
].filter(Boolean),
|
||||
edges: [
|
||||
{
|
||||
edge_id: "cursor-memory",
|
||||
source_node_id: "source:codex",
|
||||
target_node_id: "memory:m0",
|
||||
relation: "evidence_source",
|
||||
origin: "provenance",
|
||||
},
|
||||
{
|
||||
edge_id: "workbuddy-memory",
|
||||
source_node_id: "source:workbuddy",
|
||||
target_node_id: "memory:workbuddy-memory",
|
||||
relation: "evidence_source",
|
||||
origin: "provenance",
|
||||
},
|
||||
],
|
||||
} as MemoryGraphResponse;
|
||||
const cursorGraph = {
|
||||
...allGraph,
|
||||
total_memories: 1,
|
||||
projected_memories: 1,
|
||||
nodes: allGraph.nodes.filter(
|
||||
(node) => node.node_id === "memory:m0" || node.node_id === "source:codex",
|
||||
),
|
||||
edges: [allGraph.edges[0]],
|
||||
};
|
||||
|
||||
function ScopeHarness() {
|
||||
const [source, setSource] = useState("codex");
|
||||
return (
|
||||
<Host
|
||||
simplified
|
||||
items={[memories[0]]}
|
||||
graphValue={source ? cursorGraph : allGraph}
|
||||
sources={
|
||||
[
|
||||
{
|
||||
source_id: "codex",
|
||||
display_name: "Cursor",
|
||||
authorized: true,
|
||||
},
|
||||
{
|
||||
source_id: "workbuddy",
|
||||
display_name: "WorkBuddy",
|
||||
authorized: true,
|
||||
},
|
||||
{
|
||||
source_id: "retired",
|
||||
display_name: "Retired",
|
||||
authorized: false,
|
||||
},
|
||||
] as SourceResponse[]
|
||||
}
|
||||
libraryView={{
|
||||
...pagedView({
|
||||
items: [memories[0]],
|
||||
total: source ? 1 : 2,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
source_ids_by_memory: { m0: ["codex"] },
|
||||
}),
|
||||
source,
|
||||
onSource: setSource,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<ScopeHarness />);
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "图中选择 较早的 WorkBuddy 记忆",
|
||||
}),
|
||||
).toBeNull();
|
||||
await userEvent.click(screen.getByRole("combobox", { name: "来源" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: "全部应用" }));
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "图中选择 较早的 WorkBuddy 记忆",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "图中选择 WorkBuddy" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
function pagedView(
|
||||
page: Parameters<typeof MemoryMap>[0]["libraryView"] extends infer V
|
||||
? V extends { page: infer P }
|
||||
@@ -580,6 +806,8 @@ it("keeps pagination and current detail stable while the next page is loading",
|
||||
const libraryView = { ...pagedView(page), onPage };
|
||||
const view = render(<Host simplified libraryView={libraryView} />);
|
||||
const list = screen.getByRole("list", { name: "地图记忆列表" });
|
||||
const statusSlot = document.querySelector(".map-list-status");
|
||||
expect(statusSlot?.getAttribute("data-visible")).toBe("false");
|
||||
list.scrollTop = 160;
|
||||
const pager = screen.getByRole("navigation", { name: "地图记忆分页" });
|
||||
await userEvent.click(
|
||||
@@ -594,6 +822,8 @@ it("keeps pagination and current detail stable while the next page is loading",
|
||||
.getByRole("navigation", { name: "地图记忆分页" })
|
||||
.getAttribute("aria-busy"),
|
||||
).toBe("true");
|
||||
expect(document.querySelector(".map-list-status")).toBe(statusSlot);
|
||||
expect(statusSlot?.getAttribute("data-visible")).toBe("true");
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", {
|
||||
@@ -629,10 +859,131 @@ it("keeps pagination and current detail stable while the next page is loading",
|
||||
/>,
|
||||
);
|
||||
expect(list.scrollTop).toBe(0);
|
||||
expect(document.querySelector(".map-list-status")).toBe(statusSlot);
|
||||
expect(statusSlot?.getAttribute("data-visible")).toBe("false");
|
||||
expect(within(list).getByText("下一页第 1 条")).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(),
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "下一页第 1 条" }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(list)
|
||||
.getByRole("button", { name: /下一页第 1 条/ })
|
||||
.getAttribute("aria-pressed"),
|
||||
).toBe("true");
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the page intent through a failed read and selects the first row after retry", async () => {
|
||||
const onPage = vi.fn();
|
||||
const onRetry = vi.fn();
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 202,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const base = { ...pagedView(page), onPage, onRetry };
|
||||
const view = render(<Host simplified libraryView={base} />);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "下一页记忆" }));
|
||||
expect(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" }))
|
||||
.getByRole("button", { name: /第一条/ })
|
||||
.hasAttribute("disabled"),
|
||||
).toBe(true);
|
||||
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...base,
|
||||
loading: false,
|
||||
failed: true,
|
||||
requestedOffset: 100,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "重新读取地图记忆" }),
|
||||
);
|
||||
expect(onRetry).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" }))
|
||||
.getByRole("button", { name: /第一条/ })
|
||||
.hasAttribute("disabled"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", {
|
||||
name: "搜索",
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
|
||||
const nextItems = memories.map((memory, index) => ({
|
||||
...memory,
|
||||
memory_id: `retry-${index}`,
|
||||
content: `重试页第 ${index + 1} 条`,
|
||||
}));
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
items={nextItems}
|
||||
libraryView={{
|
||||
...base,
|
||||
requestedOffset: 100,
|
||||
page: {
|
||||
...page,
|
||||
items: nextItems,
|
||||
offset: 100,
|
||||
source_ids_by_memory: {
|
||||
"retry-0": ["codex"],
|
||||
"retry-1": ["codex"],
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
failed: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "重试页第 1 条" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns to the shown page when a failed page request is cancelled by a row click", async () => {
|
||||
const onPage = vi.fn();
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 202,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const base = { ...pagedView(page), onPage };
|
||||
const view = render(<Host simplified libraryView={base} />);
|
||||
await act(async () => {});
|
||||
await userEvent.click(screen.getByRole("button", { name: "下一页记忆" }));
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
libraryView={{
|
||||
...base,
|
||||
failed: true,
|
||||
requestedOffset: 100,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
"button",
|
||||
{ name: /第二条/ },
|
||||
),
|
||||
);
|
||||
expect(onPage.mock.calls).toEqual([[100], [0]]);
|
||||
expect(await screen.findByRole("heading", { name: "第二条" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
|
||||
@@ -712,8 +1063,10 @@ it("badges each listed memory with its scene and pages 20 at a time", () => {
|
||||
expect((next as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(next);
|
||||
expect(view.onPage).toHaveBeenCalledWith(40);
|
||||
fireEvent.click(screen.getByRole("button", { name: "上一页记忆" }));
|
||||
expect(view.onPage).toHaveBeenCalledWith(0);
|
||||
const previous = screen.getByRole("button", { name: "上一页记忆" });
|
||||
expect((previous as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(previous);
|
||||
expect(view.onPage).not.toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("says a filtered source is empty rather than that nothing matched", () => {
|
||||
@@ -1238,7 +1591,8 @@ it("drops a pending source selection when that source is revoked", async () => {
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "图中选择 Codex" })).toBeNull();
|
||||
// Memories already distilled from a revoked source stay on the map, but the
|
||||
// revoked source can no longer be the selection.
|
||||
expect(screen.getByTestId("canvas").dataset.selected).not.toBe(
|
||||
"source:codex",
|
||||
);
|
||||
|
||||
@@ -95,9 +95,13 @@ type Props = {
|
||||
page: MemoryLibraryPage | null;
|
||||
loading: boolean;
|
||||
failed: boolean;
|
||||
/** The graph for the current source/query scope has not arrived yet. */
|
||||
graphLoading?: boolean;
|
||||
graphFailed?: boolean;
|
||||
stale: boolean;
|
||||
settledSource?: string;
|
||||
settledQuery?: string;
|
||||
requestedOffset?: number;
|
||||
/** The source id the SDK uses for "no source given" (shown as 未识别来源). */
|
||||
unattributedSource?: string;
|
||||
onQuery(value: string): void;
|
||||
@@ -221,6 +225,15 @@ export function MemoryMap(props: Props) {
|
||||
const [pendingSourceSelection, setPendingSourceSelection] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [pendingPageOffset, setPendingPageOffset] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [pageRetrying, setPageRetrying] = useState(false);
|
||||
const pageRetrySawLoading = useRef(false);
|
||||
const [detailResetRevision, setDetailResetRevision] = useState(0);
|
||||
const pageTransitioning =
|
||||
!!library?.loading ||
|
||||
(pendingPageOffset !== null && (!library?.failed || pageRetrying));
|
||||
const [question, setQuestion] = useState("");
|
||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||
const [filterNotice, setFilterNotice] = useState("");
|
||||
@@ -316,6 +329,31 @@ export function MemoryMap(props: Props) {
|
||||
[active, group, search, serverPaged, axis, groupKey],
|
||||
);
|
||||
const projection = useMemo(() => {
|
||||
// Until the new scope's graph arrives (or after it failed), drawing the
|
||||
// previous scope's graph would show the wrong source.
|
||||
if (serverPaged && (library?.graphLoading || library?.graphFailed))
|
||||
return {
|
||||
total_memories: library.page?.total ?? 0,
|
||||
projected_memories: 0,
|
||||
hy_graph_available: false,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
};
|
||||
// Source filtering owns the graph scope, while pagination owns only the
|
||||
// left-hand list window. Projecting the graph back down to the current
|
||||
// 20-row page made "All applications" look like whichever source happened
|
||||
// to dominate that page. Keyword search is intentionally page-scoped.
|
||||
if (serverPaged && !library?.query) {
|
||||
return (
|
||||
baseGraph ??
|
||||
projectMap(
|
||||
graph,
|
||||
active,
|
||||
axis,
|
||||
filtered.map((m) => m.memory_id),
|
||||
)
|
||||
);
|
||||
}
|
||||
const result = projectMap(
|
||||
graph,
|
||||
active,
|
||||
@@ -347,7 +385,21 @@ export function MemoryMap(props: Props) {
|
||||
confidence: null,
|
||||
})),
|
||||
};
|
||||
}, [graph, axis, filtered, active, groupKey, search, sources, serverPaged]);
|
||||
}, [
|
||||
baseGraph,
|
||||
graph,
|
||||
axis,
|
||||
filtered,
|
||||
active,
|
||||
groupKey,
|
||||
search,
|
||||
sources,
|
||||
serverPaged,
|
||||
library?.query,
|
||||
library?.graphLoading,
|
||||
library?.graphFailed,
|
||||
library?.page?.total,
|
||||
]);
|
||||
const visibleGroupNode = groupNode
|
||||
? (projection.nodes.find(
|
||||
(node) =>
|
||||
@@ -384,6 +436,7 @@ export function MemoryMap(props: Props) {
|
||||
: selectedSource
|
||||
? `source:${selectedSource.source_id}`
|
||||
: (visibleGroupNode?.node_id ?? "empty");
|
||||
const inspectorResetKey = `${inspectorIdentity}:${detailResetRevision}`;
|
||||
const visibleDetail =
|
||||
detail &&
|
||||
(!activeId || activeId === detail.memory.memory_id) &&
|
||||
@@ -395,10 +448,24 @@ export function MemoryMap(props: Props) {
|
||||
const inspectorDetail =
|
||||
visibleDetail ??
|
||||
(activeId && lastDetail.current ? lastDetail.current : null);
|
||||
const readingActiveDetail =
|
||||
!!activeId &&
|
||||
props.detailLoad?.memoryId === activeId &&
|
||||
props.detailLoad.status === "loading";
|
||||
const failedActiveDetail =
|
||||
!!activeId &&
|
||||
props.detailLoad?.memoryId === activeId &&
|
||||
props.detailLoad.status === "failed";
|
||||
const pick = useCallback(
|
||||
(memoryId: string, scroll = true) => {
|
||||
if (!active.some((memory) => memory.memory_id === memoryId)) return;
|
||||
if (pendingPageOffset !== null && library?.failed && library.page)
|
||||
library.onPage(library.page.offset);
|
||||
setPendingSourceSelection(null);
|
||||
setPendingPageOffset(null);
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setDetailResetRevision((current) => current + 1);
|
||||
scrollSelection.current = scroll;
|
||||
if (!filtered.some((memory) => memory.memory_id === memoryId)) {
|
||||
setGroupKey("");
|
||||
@@ -410,7 +477,7 @@ export function MemoryMap(props: Props) {
|
||||
setInspectorOpen(true);
|
||||
onOpenMemory(memoryId);
|
||||
},
|
||||
[active, filtered, onOpenMemory],
|
||||
[active, filtered, library, onOpenMemory, pendingPageOffset],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -438,6 +505,24 @@ export function MemoryMap(props: Props) {
|
||||
pick,
|
||||
selectedSource,
|
||||
]);
|
||||
useLayoutEffect(() => {
|
||||
if (pendingPageOffset === null || !library || library.loading) return;
|
||||
if (library.failed || library.stale) return;
|
||||
const expectedOffset = library.requestedOffset ?? pendingPageOffset;
|
||||
if (library.page?.offset !== expectedOffset) return;
|
||||
setPendingPageOffset(null);
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
const first = active[0];
|
||||
if (first) {
|
||||
pick(first.memory_id, false);
|
||||
return;
|
||||
}
|
||||
setSelectedId(null);
|
||||
setGroupNode(null);
|
||||
setInspectorOpen(false);
|
||||
onCloseDetail();
|
||||
}, [active, library, onCloseDetail, pendingPageOffset, pick]);
|
||||
useEffect(() => {
|
||||
if (!props.simplified || openedDefault.current || overlaysSuspended) return;
|
||||
if (library?.loading && filtered.length === 0) return;
|
||||
@@ -455,6 +540,17 @@ export function MemoryMap(props: Props) {
|
||||
detail,
|
||||
pick,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!pageRetrying || !library) return;
|
||||
if (library.loading) {
|
||||
pageRetrySawLoading.current = true;
|
||||
return;
|
||||
}
|
||||
if (pageRetrySawLoading.current && library.failed) {
|
||||
pageRetrySawLoading.current = false;
|
||||
setPageRetrying(false);
|
||||
}
|
||||
}, [library, pageRetrying]);
|
||||
useEffect(() => {
|
||||
if (!activeId || !scrollSelection.current) return;
|
||||
scrollSelection.current = false;
|
||||
@@ -474,9 +570,9 @@ export function MemoryMap(props: Props) {
|
||||
useLayoutEffect(() => {
|
||||
// Detail content owns its own scrollport. A newly selected memory must
|
||||
// start at its heading instead of inheriting the previous inspector view.
|
||||
if (inspectorIdentity === "empty") return;
|
||||
if (inspectorResetKey.startsWith("empty:")) return;
|
||||
if (inspectorBody.current) inspectorBody.current.scrollTop = 0;
|
||||
}, [inspectorIdentity]);
|
||||
}, [inspectorResetKey]);
|
||||
const settledPageOffset = library?.page?.offset;
|
||||
useLayoutEffect(() => {
|
||||
// A settled page is a new reading surface, not a continuation of the
|
||||
@@ -489,12 +585,16 @@ export function MemoryMap(props: Props) {
|
||||
setSelectedId(null);
|
||||
setGroupNode(null);
|
||||
setPendingSourceSelection(null);
|
||||
setPendingPageOffset(null);
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setInspectorOpen(false);
|
||||
onCloseDetail();
|
||||
}, [onCloseDetail]);
|
||||
useEffect(() => {
|
||||
// A background map does not own the shared inspector in another mode.
|
||||
if (overlaysSuspended || library?.loading) return;
|
||||
if (overlaysSuspended || library?.loading || pendingPageOffset !== null)
|
||||
return;
|
||||
if (
|
||||
requestedId &&
|
||||
!active.some((memory) => memory.memory_id === requestedId)
|
||||
@@ -514,6 +614,7 @@ export function MemoryMap(props: Props) {
|
||||
clear,
|
||||
overlaysSuspended,
|
||||
library?.loading,
|
||||
pendingPageOffset,
|
||||
]);
|
||||
function closeInspector() {
|
||||
if (!compact) {
|
||||
@@ -523,6 +624,14 @@ export function MemoryMap(props: Props) {
|
||||
setPendingSourceSelection(null);
|
||||
setInspectorOpen(false);
|
||||
}
|
||||
function retryLibrary() {
|
||||
if (!library) return;
|
||||
if (pendingPageOffset !== null) {
|
||||
pageRetrySawLoading.current = false;
|
||||
setPageRetrying(true);
|
||||
}
|
||||
library.onRetry();
|
||||
}
|
||||
function chooseGroup(key: string) {
|
||||
setGroupNode(null);
|
||||
if (library) {
|
||||
@@ -577,6 +686,17 @@ export function MemoryMap(props: Props) {
|
||||
? text("未识别来源")
|
||||
: null) ??
|
||||
text("全部记忆");
|
||||
const libraryStatus = !library
|
||||
? ""
|
||||
: library.failed
|
||||
? text("读取失败")
|
||||
: library.stale
|
||||
? text("正在核对来源…")
|
||||
: pageTransitioning
|
||||
? refreshing && library.page?.total !== 0
|
||||
? text("正在核对…")
|
||||
: text("正在读取…")
|
||||
: "";
|
||||
if (
|
||||
props.simplified &&
|
||||
(!library ||
|
||||
@@ -777,21 +897,20 @@ export function MemoryMap(props: Props) {
|
||||
<button
|
||||
type="submit"
|
||||
className="map-search-submit"
|
||||
disabled={library.loading || hidden}
|
||||
disabled={pageTransitioning || hidden}
|
||||
>
|
||||
{text("搜索")}
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
{library && (library.loading || library.stale || library.failed) ? (
|
||||
<p className="map-list-status" aria-live="polite">
|
||||
{library.failed
|
||||
? text("读取失败")
|
||||
: library.stale
|
||||
? text("正在核对来源…")
|
||||
: refreshing && library.page?.total !== 0
|
||||
? text("正在核对…")
|
||||
: text("正在读取…")}
|
||||
{library ? (
|
||||
<p
|
||||
className="map-list-status"
|
||||
aria-live="polite"
|
||||
aria-hidden={!libraryStatus || undefined}
|
||||
data-visible={libraryStatus ? "true" : "false"}
|
||||
>
|
||||
{libraryStatus || "\u00a0"}
|
||||
</p>
|
||||
) : null}
|
||||
{!props.simplified && (
|
||||
@@ -861,6 +980,7 @@ export function MemoryMap(props: Props) {
|
||||
type="button"
|
||||
className="map-memory-item"
|
||||
aria-pressed={activeId === m.memory_id}
|
||||
disabled={pageTransitioning}
|
||||
onClick={() => pick(m.memory_id)}
|
||||
>
|
||||
<span className={`map-state ${m.status}`} aria-hidden="true" />
|
||||
@@ -914,7 +1034,7 @@ export function MemoryMap(props: Props) {
|
||||
{library?.failed && (
|
||||
<div role="alert">
|
||||
<p>{text("记忆读取失败,请重试。")}</p>
|
||||
<R6Button onClick={library.onRetry}>
|
||||
<R6Button onClick={retryLibrary}>
|
||||
{text("重新读取地图记忆")}
|
||||
</R6Button>
|
||||
</div>
|
||||
@@ -949,7 +1069,7 @@ export function MemoryMap(props: Props) {
|
||||
!library.stale && (
|
||||
<nav
|
||||
aria-label={text("地图记忆分页")}
|
||||
aria-busy={library.loading || undefined}
|
||||
aria-busy={pageTransitioning || undefined}
|
||||
className="map-pagination"
|
||||
>
|
||||
<button
|
||||
@@ -957,11 +1077,16 @@ export function MemoryMap(props: Props) {
|
||||
className="quiet map-tool"
|
||||
aria-label={text("上一页记忆")}
|
||||
title={text("上一页记忆")}
|
||||
disabled={library.loading || library.page.offset === 0}
|
||||
disabled={pageTransitioning || library.page.offset === 0}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page)
|
||||
library.onPage(library.page.offset - library.page.limit);
|
||||
if (library.page) {
|
||||
const offset = library.page.offset - library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={16} aria-hidden="true" />
|
||||
@@ -987,13 +1112,18 @@ export function MemoryMap(props: Props) {
|
||||
aria-label={text("下一页记忆")}
|
||||
title={text("下一页记忆")}
|
||||
disabled={
|
||||
library.loading ||
|
||||
pageTransitioning ||
|
||||
library.page.offset + library.page.limit >= library.page.total
|
||||
}
|
||||
onClick={() => {
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page)
|
||||
library.onPage(library.page.offset + library.page.limit);
|
||||
if (library.page) {
|
||||
const offset = library.page.offset + library.page.limit;
|
||||
setPageRetrying(false);
|
||||
pageRetrySawLoading.current = false;
|
||||
setPendingPageOffset(offset);
|
||||
library.onPage(offset);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={16} aria-hidden="true" />
|
||||
@@ -1059,22 +1189,33 @@ export function MemoryMap(props: Props) {
|
||||
) : (
|
||||
<div className="map-empty-stage">
|
||||
<Network size={32} strokeWidth={1} />
|
||||
<p>
|
||||
<p
|
||||
{...(library?.graphFailed || library?.graphLoading
|
||||
? { role: "status", "aria-live": "polite" as const }
|
||||
: {})}
|
||||
>
|
||||
{library?.stale
|
||||
? text("可在沉淀记忆后查看视图")
|
||||
: library?.failed
|
||||
: library?.graphFailed || library?.failed
|
||||
? text("记忆暂时无法读取")
|
||||
: library?.page &&
|
||||
(library.query || library.source || groupKey)
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: !loaded
|
||||
? text("正在读取记忆…")
|
||||
: active.length
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
: library?.graphLoading
|
||||
? text("正在读取记忆地图…")
|
||||
: library?.page &&
|
||||
(library.query || library.source || groupKey)
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: !loaded
|
||||
? text("正在读取记忆…")
|
||||
: active.length
|
||||
? text("当前筛选没有可显示的记忆")
|
||||
: arrival.importedMessages > 0
|
||||
? text("已保存的对话还未生成记忆")
|
||||
: text("还没有可显示的记忆")}
|
||||
</p>
|
||||
{library?.graphFailed ? (
|
||||
<R6Button onClick={library.onRetry}>
|
||||
{text("重新读取地图记忆")}
|
||||
</R6Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1167,7 +1308,7 @@ export function MemoryMap(props: Props) {
|
||||
<MemoryEvidencePanel
|
||||
open={inspectorOpen && !!(activeId || visibleGroupNode)}
|
||||
suspended={overlaysSuspended}
|
||||
scrollResetKey={activeId ?? visibleGroupNode?.node_id ?? "empty"}
|
||||
scrollResetKey={inspectorResetKey}
|
||||
onClose={closeInspector}
|
||||
>
|
||||
<header className="map-inspector-heading">
|
||||
@@ -1202,7 +1343,7 @@ export function MemoryMap(props: Props) {
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={library.onRetry}
|
||||
onClick={retryLibrary}
|
||||
>
|
||||
{text("重试")}
|
||||
</button>
|
||||
@@ -1223,8 +1364,17 @@ export function MemoryMap(props: Props) {
|
||||
</div>
|
||||
) : props.simplified && inspectorDetail ? (
|
||||
<div
|
||||
key={`${inspectorDetail.memory.memory_id}:${detailResetRevision}:${
|
||||
readingActiveDetail
|
||||
? "loading"
|
||||
: failedActiveDetail
|
||||
? "failed"
|
||||
: "ready"
|
||||
}`}
|
||||
className="map-detail-surface"
|
||||
aria-busy={
|
||||
refreshing ||
|
||||
readingActiveDetail ||
|
||||
inspectorDetail.memory.memory_id !== activeId ||
|
||||
undefined
|
||||
}
|
||||
@@ -1249,7 +1399,21 @@ export function MemoryMap(props: Props) {
|
||||
until the new one arrived, which read as "nothing happened"
|
||||
for two or three seconds. A read is not a value: show that a
|
||||
read is happening instead. */}
|
||||
{activeId && inspectorDetail.memory.memory_id !== activeId ? (
|
||||
{failedActiveDetail ? (
|
||||
<div className="map-detail-loading">
|
||||
<p role="alert">
|
||||
{text("这条记忆的原文暂时无法读取,选择已保留。")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => activeId && onOpenMemory(activeId)}
|
||||
>
|
||||
{text("重试读取原文")}
|
||||
</button>
|
||||
</div>
|
||||
) : readingActiveDetail ||
|
||||
(activeId && inspectorDetail.memory.memory_id !== activeId) ? (
|
||||
<div className="map-detail-loading">
|
||||
<div className="map-detail-skeleton" aria-hidden="true">
|
||||
<i />
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SdkMemoryRecord,
|
||||
} from "@one-memory/contracts";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -63,6 +64,9 @@ vi.mock("./MemoryMap", () => ({
|
||||
source_ids_by_memory?: Record<string, string[]>;
|
||||
offset: number;
|
||||
} | null;
|
||||
graphLoading?: boolean;
|
||||
graphFailed?: boolean;
|
||||
stale: boolean;
|
||||
unattributedSource?: string;
|
||||
onSource(value: string): void;
|
||||
onQuery(value: string): void;
|
||||
@@ -91,6 +95,13 @@ vi.mock("./MemoryMap", () => ({
|
||||
<span data-testid="map-busy">{String(busy)}</span>
|
||||
<span data-testid="map-detail">{detail?.memory.memory_id ?? ""}</span>
|
||||
<span data-testid="map-offset">{libraryView?.page?.offset ?? ""}</span>
|
||||
<span data-testid="map-stale">{String(libraryView?.stale)}</span>
|
||||
<span data-testid="map-graph-loading">
|
||||
{String(libraryView?.graphLoading)}
|
||||
</span>
|
||||
<span data-testid="map-graph-failed">
|
||||
{String(libraryView?.graphFailed)}
|
||||
</span>
|
||||
<button type="button" onClick={() => onOpenMemory("e1")}>
|
||||
open e1
|
||||
</button>
|
||||
@@ -133,6 +144,15 @@ vi.mock("./MemoryMap", () => ({
|
||||
>
|
||||
pick workbuddy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
libraryView?.onSource("");
|
||||
libraryView?.resetPage();
|
||||
}}
|
||||
>
|
||||
pick all
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -214,11 +234,23 @@ it("draws the map from SDK memories on SDK authority, without legacy reads", asy
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(api.sdkGraph).toHaveBeenCalledWith(
|
||||
{ source: undefined, offset: 0, limit: 100 },
|
||||
{ source: undefined, offset: 0, limit: 300 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a graph read failure explicit instead of presenting the list page as the graph", async () => {
|
||||
sdkAuthority();
|
||||
api.sdkGraph.mockRejectedValue(new Error("graph unavailable"));
|
||||
render(<MemorySpace simplified active />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-loaded").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-graph-failed").textContent).toBe("true");
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
});
|
||||
|
||||
it("keeps rows, sources, count and graph on the one filtered page", async () => {
|
||||
sdkAuthority();
|
||||
render(<MemorySpace simplified active />);
|
||||
@@ -251,12 +283,150 @@ it("keeps rows, sources, count and graph on the one filtered page", async () =>
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(api.sdkGraph).toHaveBeenLastCalledWith(
|
||||
{ source: "workbuddy", offset: 0, limit: 100 },
|
||||
{ source: "workbuddy", offset: 0, limit: 300 },
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(await screen.findByText(/566/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the previous source graph until the all-applications graph settles", async () => {
|
||||
sdkAuthority();
|
||||
render(<MemorySpace simplified active />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("3"),
|
||||
);
|
||||
|
||||
const workbuddyPage = {
|
||||
memories: [{ ...record("w1"), source: "workbuddy" }],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
const workbuddyGraph: MemoryGraphResponse = {
|
||||
total_memories: 1,
|
||||
projected_memories: 1,
|
||||
nodes: [
|
||||
{
|
||||
node_id: "memory:w1",
|
||||
node_type: "memory",
|
||||
label: "w1",
|
||||
memory_id: "w1",
|
||||
source_id: null,
|
||||
kind: null,
|
||||
status: "active",
|
||||
confidence: null,
|
||||
},
|
||||
{
|
||||
node_id: "source:workbuddy",
|
||||
node_type: "source",
|
||||
label: "WorkBuddy",
|
||||
source_id: "workbuddy",
|
||||
memory_id: null,
|
||||
kind: null,
|
||||
status: null,
|
||||
confidence: null,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
hy_graph_available: false,
|
||||
};
|
||||
api.sdkLibrary.mockResolvedValue(workbuddyPage);
|
||||
api.sdkGraph.mockResolvedValue(workbuddyGraph);
|
||||
fireEvent.click(screen.getByText("pick workbuddy"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("2"),
|
||||
);
|
||||
|
||||
const allPage = {
|
||||
memories: [record("a"), { ...record("w1"), source: "workbuddy" }],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
const allGraph: MemoryGraphResponse = {
|
||||
...workbuddyGraph,
|
||||
total_memories: 2,
|
||||
projected_memories: 2,
|
||||
nodes: [
|
||||
...workbuddyGraph.nodes,
|
||||
{
|
||||
node_id: "memory:a",
|
||||
node_type: "memory",
|
||||
label: "a",
|
||||
memory_id: "a",
|
||||
source_id: null,
|
||||
kind: null,
|
||||
status: "active",
|
||||
confidence: null,
|
||||
},
|
||||
{
|
||||
node_id: "source:cursor",
|
||||
node_type: "source",
|
||||
label: "Cursor",
|
||||
source_id: "cursor",
|
||||
memory_id: null,
|
||||
kind: null,
|
||||
status: null,
|
||||
confidence: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
let releasePage: (value: typeof allPage) => void = () => {};
|
||||
let releaseGraph: (value: MemoryGraphResponse) => void = () => {};
|
||||
api.sdkLibrary.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releasePage = resolve;
|
||||
}),
|
||||
);
|
||||
api.sdkGraph.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releaseGraph = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("pick all"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
// Waiting for the graph is not page staleness: the list stays usable.
|
||||
expect(screen.getByTestId("map-stale").textContent).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
releasePage(allPage);
|
||||
releaseGraph(allGraph);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("4"),
|
||||
);
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false");
|
||||
|
||||
const searchPage = {
|
||||
memories: [record("search-result")],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
let releaseSearch: (value: typeof searchPage) => void = () => {};
|
||||
api.sdkLibrary.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
releaseSearch = resolve;
|
||||
}),
|
||||
);
|
||||
const graphCalls = api.sdkGraph.mock.calls.length;
|
||||
fireEvent.click(screen.getByText("search deploy"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("true"),
|
||||
);
|
||||
expect(screen.getByTestId("map-nodes").textContent).toBe("none");
|
||||
expect(api.sdkGraph).toHaveBeenCalledTimes(graphCalls);
|
||||
|
||||
await act(async () => releaseSearch(searchPage));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("map-graph-loading").textContent).toBe("false"),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens an SDK memory in place instead of switching away from the map", async () => {
|
||||
sdkAuthority();
|
||||
api.sdkMemory.mockResolvedValue(record("a"));
|
||||
|
||||
@@ -166,11 +166,14 @@ export function MemorySpace({
|
||||
// The SDK library is no longer where a memory opens from the map; the id is
|
||||
// kept so the panel still has a focus target for its own tests.
|
||||
const [sdkFocusedMemoryId] = useState<string | null>(null);
|
||||
// The list, its count, its source and time labels and the graph are all one
|
||||
// page: the library page and the graph for that same source and window,
|
||||
// read together. Reading the graph from a different, unfiltered window made
|
||||
// rows, sources and totals disagree.
|
||||
const [sdkGraph, setSdkGraph] = useState<MemoryGraphResponse | null>(null);
|
||||
// The graph covers the active source scope (up to its visual cap), while the
|
||||
// list pages through that scope. Keep source+query with the value so a
|
||||
// filter change cannot paint the previous filter's graph during the new read.
|
||||
const [sdkGraph, setSdkGraph] = useState<{
|
||||
scope: string;
|
||||
status: "ready" | "failed";
|
||||
value: MemoryGraphResponse | null;
|
||||
} | null>(null);
|
||||
// Bumping this re-reads the page and graph after an edit or a delete.
|
||||
const [sdkMapRevision, setSdkMapRevision] = useState(0);
|
||||
const bumpSdkMap = useCallback(() => setSdkMapRevision((v) => v + 1), []);
|
||||
@@ -191,6 +194,17 @@ export function MemorySpace({
|
||||
const [sdkLibraryQuery, setSdkLibraryQuery] = useState("");
|
||||
const [sdkLibrarySource, setSdkLibrarySource] = useState("");
|
||||
const [sdkLibraryOffset, setSdkLibraryOffset] = useState(0);
|
||||
const sdkRequestedScope = JSON.stringify([
|
||||
sdkLibraryQuery.trim(),
|
||||
sdkLibrarySource,
|
||||
]);
|
||||
const sdkGraphSettled = sdkGraph?.scope === sdkRequestedScope;
|
||||
const visibleSdkGraph =
|
||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "ready"
|
||||
? sdkGraph.value
|
||||
: null;
|
||||
const sdkGraphFailed =
|
||||
sdkGraph?.scope === sdkRequestedScope && sdkGraph.status === "failed";
|
||||
// A keyword search pages through one server-side result window. The id is
|
||||
// kept for the query and source it was searched with; a new query or
|
||||
// source starts without one, so the server searches afresh.
|
||||
@@ -233,22 +247,26 @@ export function MemorySpace({
|
||||
request.signal,
|
||||
),
|
||||
// The overlay is decoration: the list stands without it. It reads
|
||||
// chat memories only, newest first; a mixed page's chat rows are
|
||||
// among the newest offset+page chat memories, so that prefix holds
|
||||
// every relation the page can draw.
|
||||
getSdkMemoryGraph(
|
||||
{
|
||||
source,
|
||||
offset: 0,
|
||||
limit: query
|
||||
? 100
|
||||
: Math.min(
|
||||
300,
|
||||
Math.max(100, sdkLibraryOffset + SDK_MEMORY_PAGE_SIZE),
|
||||
),
|
||||
},
|
||||
request.signal,
|
||||
).catch(() => null),
|
||||
// the full active/source-filtered scene up to the API's visual cap.
|
||||
// Reading only the first list-page prefix made "All applications"
|
||||
// look identical to Cursor whenever newer Cursor memories occupied
|
||||
// that prefix.
|
||||
query
|
||||
? Promise.resolve({
|
||||
status: "ready" as const,
|
||||
value: null,
|
||||
})
|
||||
: getSdkMemoryGraph(
|
||||
{
|
||||
source,
|
||||
offset: 0,
|
||||
limit: 300,
|
||||
},
|
||||
request.signal,
|
||||
).then(
|
||||
(value) => ({ status: "ready" as const, value }),
|
||||
() => ({ status: "failed" as const, value: null }),
|
||||
),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (sdkSearchWindow.current.key === windowKey)
|
||||
@@ -265,7 +283,7 @@ export function MemorySpace({
|
||||
settledSource: sdkLibrarySource,
|
||||
settledQuery: sdkLibraryQuery,
|
||||
});
|
||||
setSdkGraph(graph);
|
||||
setSdkGraph({ scope: windowKey, ...graph });
|
||||
} catch {
|
||||
if (cancelled || request.signal.aborted) return;
|
||||
setSdkLibrary((current) => ({
|
||||
@@ -1026,9 +1044,12 @@ export function MemorySpace({
|
||||
page: sdkLibrary.page,
|
||||
loading: sdkLibrary.loading,
|
||||
failed: sdkLibrary.failed,
|
||||
graphLoading: !sdkGraphSettled,
|
||||
graphFailed: sdkGraphFailed,
|
||||
stale: false,
|
||||
settledSource: sdkLibrary.settledSource,
|
||||
settledQuery: sdkLibrary.settledQuery,
|
||||
requestedOffset: sdkLibraryOffset,
|
||||
unattributedSource: UNATTRIBUTED_SOURCE_ID,
|
||||
onQuery: setSdkLibraryQuery,
|
||||
onSource: setSdkLibrarySource,
|
||||
@@ -1040,7 +1061,7 @@ export function MemorySpace({
|
||||
}
|
||||
hidden={!active || displayedMode !== "map"}
|
||||
memories={sdkLibrary.page?.items ?? []}
|
||||
graph={sdkGraph}
|
||||
graph={visibleSdkGraph}
|
||||
sources={sources}
|
||||
processing={processing}
|
||||
arrival={memoryArrival}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
|
||||
export interface OptionMenuItem {
|
||||
@@ -8,6 +17,43 @@ export interface OptionMenuItem {
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
const PANEL_GAP = 4;
|
||||
const MAX_PANEL_HEIGHT = 280;
|
||||
|
||||
export function optionMenuPosition(
|
||||
anchor: Pick<DOMRect, "top" | "right" | "bottom" | "left" | "width">,
|
||||
panelHeight: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
) {
|
||||
const width = Math.min(
|
||||
anchor.width,
|
||||
Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2),
|
||||
);
|
||||
const left = Math.min(
|
||||
Math.max(VIEWPORT_MARGIN, anchor.left),
|
||||
Math.max(VIEWPORT_MARGIN, viewportWidth - VIEWPORT_MARGIN - width),
|
||||
);
|
||||
const below = Math.max(
|
||||
0,
|
||||
viewportHeight - VIEWPORT_MARGIN - anchor.bottom - PANEL_GAP,
|
||||
);
|
||||
const above = Math.max(0, anchor.top - VIEWPORT_MARGIN - PANEL_GAP);
|
||||
const placement: "above" | "below" =
|
||||
below >= Math.min(panelHeight, 120) || below >= above ? "below" : "above";
|
||||
const maxHeight = Math.min(
|
||||
MAX_PANEL_HEIGHT,
|
||||
placement === "below" ? below : above,
|
||||
);
|
||||
const height = Math.min(panelHeight, maxHeight);
|
||||
const top =
|
||||
placement === "below"
|
||||
? anchor.bottom + PANEL_GAP
|
||||
: anchor.top - PANEL_GAP - height;
|
||||
return { top, left, width, maxHeight, placement };
|
||||
}
|
||||
|
||||
/** Replaces a native select so the open list follows the product field, not the OS menu. */
|
||||
export function OptionMenu(props: {
|
||||
id?: string;
|
||||
@@ -17,6 +63,7 @@ export function OptionMenu(props: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
options: readonly OptionMenuItem[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
@@ -32,8 +79,14 @@ export function OptionMenu(props: {
|
||||
? selectedIndex
|
||||
: firstEnabledIndex;
|
||||
const [activeIndex, setActiveIndex] = useState(preferredIndex);
|
||||
const [panelStyle, setPanelStyle] = useState<CSSProperties>({
|
||||
visibility: "hidden",
|
||||
});
|
||||
const [placement, setPlacement] = useState<"above" | "below">("below");
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const panelMouseDown = useRef(false);
|
||||
const selected = props.options.find((item) => item.value === props.value);
|
||||
const visibleLabel = selected?.label ?? t("wb.select");
|
||||
const controlId = props.id ?? `${generatedId}-control`;
|
||||
@@ -62,20 +115,77 @@ export function OptionMenu(props: {
|
||||
button.current?.focus();
|
||||
}
|
||||
|
||||
const placePanel = useCallback(() => {
|
||||
const anchor = button.current?.getBoundingClientRect();
|
||||
const menu = panel.current;
|
||||
if (!anchor || !menu) return;
|
||||
if (
|
||||
anchor.width > 0 &&
|
||||
(anchor.bottom <= 0 ||
|
||||
anchor.top >= window.innerHeight ||
|
||||
anchor.right <= 0 ||
|
||||
anchor.left >= window.innerWidth)
|
||||
) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const measuredWidth = Math.min(
|
||||
anchor.width,
|
||||
Math.max(0, window.innerWidth - VIEWPORT_MARGIN * 2),
|
||||
);
|
||||
menu.style.width = `${measuredWidth}px`;
|
||||
menu.style.maxHeight = `${MAX_PANEL_HEIGHT}px`;
|
||||
const next = optionMenuPosition(
|
||||
anchor,
|
||||
menu.scrollHeight,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
);
|
||||
setPlacement(next.placement);
|
||||
setPanelStyle({
|
||||
top: next.top,
|
||||
left: next.left,
|
||||
width: next.width,
|
||||
maxHeight: next.maxHeight,
|
||||
visibility: "visible",
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
placePanel();
|
||||
window.addEventListener("resize", placePanel);
|
||||
window.addEventListener("scroll", placePanel, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", placePanel);
|
||||
window.removeEventListener("scroll", placePanel, true);
|
||||
};
|
||||
}, [open, placePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setActiveIndex(preferredIndex);
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
||||
const target = event.target as Node;
|
||||
if (!root.current?.contains(target) && !panel.current?.contains(target))
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
const releasePanelPointer = () => {
|
||||
panelMouseDown.current = false;
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
document.addEventListener("mouseup", releasePanelPointer);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("blur", releasePanelPointer);
|
||||
return () => {
|
||||
panelMouseDown.current = false;
|
||||
document.removeEventListener("mousedown", close);
|
||||
document.removeEventListener("mouseup", releasePanelPointer);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("blur", releasePanelPointer);
|
||||
};
|
||||
}, [open, preferredIndex]);
|
||||
return (
|
||||
@@ -105,7 +215,12 @@ export function OptionMenu(props: {
|
||||
}
|
||||
disabled={props.disabled}
|
||||
onBlur={(event) => {
|
||||
if (!root.current?.contains(event.relatedTarget as Node | null))
|
||||
if (panelMouseDown.current) return;
|
||||
const target = event.relatedTarget as Node | null;
|
||||
if (
|
||||
!root.current?.contains(target) &&
|
||||
!panel.current?.contains(target)
|
||||
)
|
||||
setOpen(false);
|
||||
}}
|
||||
onClick={() => {
|
||||
@@ -152,29 +267,43 @@ export function OptionMenu(props: {
|
||||
>
|
||||
{visibleLabel}
|
||||
</button>
|
||||
{open && (
|
||||
<div id={panelId} className="om-option-menu-panel" role="listbox">
|
||||
{props.options.map((item, index) => (
|
||||
<button
|
||||
key={item.value || item.label}
|
||||
id={`${panelId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
lang={item.lang}
|
||||
data-active={activeIndex === index || undefined}
|
||||
aria-selected={item.value === props.value}
|
||||
disabled={item.disabled}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panel}
|
||||
id={panelId}
|
||||
className={["om-option-menu-panel", props.panelClassName]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="listbox"
|
||||
data-placement={placement}
|
||||
style={panelStyle}
|
||||
onMouseDownCapture={() => {
|
||||
panelMouseDown.current = true;
|
||||
}}
|
||||
>
|
||||
{props.options.map((item, index) => (
|
||||
<button
|
||||
key={item.value || item.label}
|
||||
id={`${panelId}-option-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
lang={item.lang}
|
||||
data-active={activeIndex === index || undefined}
|
||||
aria-selected={item.value === props.value}
|
||||
disabled={item.disabled}
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,6 +136,70 @@ it("splits long memory text into a bounded heading and readable body", () => {
|
||||
"selected-memory-body",
|
||||
);
|
||||
});
|
||||
it("renders an SDK experience from its structured task and solution", () => {
|
||||
const task =
|
||||
"review an uncommitted diff for privacy and correctness blockers";
|
||||
const solution = `Reusable checklist:\n1. Run \`git diff --check\`.\n2. Inspect ${"/very-long-path".repeat(20)}.`;
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
memory: {
|
||||
...detail.memory,
|
||||
content: `${task}\n${solution}`,
|
||||
revision: 0,
|
||||
},
|
||||
evidence: [],
|
||||
sdk_status: "active",
|
||||
sdk_ref: {
|
||||
scene: "coding",
|
||||
kind: "experience",
|
||||
variant: "coding",
|
||||
},
|
||||
sdk_task: task,
|
||||
sdk_solution: solution,
|
||||
}}
|
||||
sources={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("heading", { name: task })).toBeTruthy();
|
||||
const body = document.querySelector(".selected-memory-body");
|
||||
expect(body?.textContent).toBe(solution);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("heading", { name: task })
|
||||
.closest("[data-memory-kind]")
|
||||
?.getAttribute("data-memory-kind"),
|
||||
).toBe("experience");
|
||||
});
|
||||
it("does not apply experience fields to a fact memory", () => {
|
||||
const content = `${"事实".repeat(40)}。补充说明`;
|
||||
render(
|
||||
<SelectedMemoryDetail
|
||||
detail={{
|
||||
...detail,
|
||||
memory: { ...detail.memory, content, revision: 0 },
|
||||
evidence: [],
|
||||
sdk_status: "active",
|
||||
sdk_ref: {
|
||||
scene: "working",
|
||||
kind: "fact",
|
||||
variant: "working",
|
||||
},
|
||||
sdk_task: "不应显示的任务标题",
|
||||
sdk_solution: "不应显示的经验正文",
|
||||
}}
|
||||
sources={[]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("不应显示的任务标题")).toBeNull();
|
||||
expect(screen.queryByText("不应显示的经验正文")).toBeNull();
|
||||
expect(
|
||||
document
|
||||
.querySelector(".selected-memory-detail")
|
||||
?.getAttribute("data-memory-kind"),
|
||||
).toBe("memory");
|
||||
});
|
||||
it.each([
|
||||
[
|
||||
"a late sentence end",
|
||||
|
||||
@@ -65,7 +65,7 @@ export function SelectedMemoryDetail({
|
||||
// (CJK text has none, so it breaks at the limit itself).
|
||||
const punctuation = memory.content.search(/[。!?!?\n]/);
|
||||
const space = memory.content.lastIndexOf(" ", HEADING_LIMIT);
|
||||
const split =
|
||||
const fallbackSplit =
|
||||
punctuation > 0 && punctuation < HEADING_LIMIT
|
||||
? punctuation + 1
|
||||
: memory.content.length > HEADING_LIMIT
|
||||
@@ -73,11 +73,26 @@ export function SelectedMemoryDetail({
|
||||
? space
|
||||
: HEADING_LIMIT
|
||||
: -1;
|
||||
const heading =
|
||||
split > 0 ? memory.content.slice(0, split).trim() : memory.content;
|
||||
const body = split > 0 ? memory.content.slice(split).trim() : "";
|
||||
const fallbackHeading =
|
||||
fallbackSplit > 0
|
||||
? memory.content.slice(0, fallbackSplit).trim()
|
||||
: memory.content;
|
||||
const fallbackBody =
|
||||
fallbackSplit > 0 ? memory.content.slice(fallbackSplit).trim() : "";
|
||||
const experience =
|
||||
detail.sdk_ref?.kind === "experience" &&
|
||||
Boolean(detail.sdk_task?.trim() || detail.sdk_solution?.trim());
|
||||
const heading = experience
|
||||
? detail.sdk_task?.trim() || fallbackHeading
|
||||
: fallbackHeading;
|
||||
const body = experience
|
||||
? detail.sdk_solution?.trim() || (detail.sdk_task ? "" : fallbackBody)
|
||||
: fallbackBody;
|
||||
return (
|
||||
<article className="selected-memory-detail">
|
||||
<article
|
||||
className="selected-memory-detail"
|
||||
data-memory-kind={experience ? "experience" : "memory"}
|
||||
>
|
||||
<p className="selected-memory-status">
|
||||
{statusLabel}
|
||||
{!detail.sdk_status &&
|
||||
|
||||
@@ -144,13 +144,12 @@ button svg {
|
||||
background: #edf0f3;
|
||||
}
|
||||
.om-option-menu-panel {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #c0ccd5;
|
||||
@@ -178,10 +177,8 @@ button svg {
|
||||
.om-option-menu-panel [role="option"]:hover:not(:disabled),
|
||||
button.om-option-menu-button:hover:not(:disabled),
|
||||
.cold-start button.om-option-menu-button:hover:not(:disabled),
|
||||
.cold-start .om-option-menu-panel button[role="option"]:hover:not(:disabled),
|
||||
.r6-settings button.om-option-menu-button:hover:not(:disabled),
|
||||
.r6-settings .om-option-menu-panel button[role="option"]:hover:not(:disabled),
|
||||
.r6-memory .om-option-menu-panel button[role="option"]:hover:not(:disabled) {
|
||||
.r6-memory button.om-option-menu-button:hover:not(:disabled) {
|
||||
color: #262a2e;
|
||||
background: #e7eef2;
|
||||
border-color: transparent;
|
||||
@@ -791,12 +788,16 @@ button.rail-button.active::before {
|
||||
}
|
||||
.map-list-status {
|
||||
flex: none;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
color: var(--tx3);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.map-list-status[data-visible="false"] {
|
||||
visibility: hidden;
|
||||
}
|
||||
.map-filter-notice {
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
@@ -882,6 +883,10 @@ button.rail-button.active::before {
|
||||
background: #fdfefe;
|
||||
border-color: #c7d3dc;
|
||||
}
|
||||
.map-memory-item:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
.map-state {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
@@ -1145,6 +1150,7 @@ button.map-tool {
|
||||
}
|
||||
.map-detail-loading {
|
||||
display: grid;
|
||||
min-height: 280px;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
@@ -1155,8 +1161,13 @@ button.map-tool {
|
||||
}
|
||||
@keyframes map-detail-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.map-detail-surface,
|
||||
.map-detail-loading {
|
||||
animation: map-detail-enter 140ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
.map-inspector .wb-detail {
|
||||
|
||||
@@ -72,37 +72,27 @@
|
||||
border-color: #7892a6;
|
||||
box-shadow: 0 0 0 3px rgb(85 124 147 / 12%);
|
||||
}
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-panel {
|
||||
top: calc(100% + 6px);
|
||||
.r6-language-option-menu-panel--settings {
|
||||
padding: 6px;
|
||||
border-color: #c6d2da;
|
||||
box-shadow: 0 12px 32px rgb(48 71 87 / 13%);
|
||||
}
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-panel [role="option"] {
|
||||
.r6-language-option-menu-panel--settings [role="option"] {
|
||||
min-height: 38px;
|
||||
padding: 8px 10px;
|
||||
color: #304757;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"][aria-selected="true"] {
|
||||
.r6-language-option-menu-panel--settings [role="option"][aria-selected="true"] {
|
||||
background: #dfe9ef;
|
||||
font-weight: 500;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"][data-active="true"] {
|
||||
.r6-language-option-menu-panel--settings [role="option"][data-active="true"] {
|
||||
color: #304757;
|
||||
background: #e9f0f4;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"]:active:not(:disabled) {
|
||||
.r6-language-option-menu-panel--settings [role="option"]:active:not(:disabled) {
|
||||
color: #304757;
|
||||
background: #d4e2ea;
|
||||
}
|
||||
|
||||
@@ -263,12 +263,14 @@
|
||||
display: block;
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 17px;
|
||||
line-height: 29px;
|
||||
letter-spacing: normal;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.selected-memory-source {
|
||||
margin: 32px 0 22px;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# MemHub 安装与发行
|
||||
|
||||
MemHub `v1.0.5`(build 14)的发行目标如下:
|
||||
MemHub `v1.0.5`(build 36)的发行目标如下:
|
||||
|
||||
| 平台 | 发行物 | 构建与验收 |
|
||||
| --- | --- | --- |
|
||||
| Linux x64 | Runtime tar.gz | GitHub Actions 原生 x64 runner |
|
||||
| Linux arm64 | Runtime tar.gz | GitHub Actions 原生 arm64 runner |
|
||||
| macOS arm64 | DMG 与 Runtime tar.gz | Developer ID 签名、公证和原生 arm64 验收 |
|
||||
| macOS x64 | DMG 与 Runtime tar.gz | Developer ID 签名、公证和原生 Intel 验收 |
|
||||
| macOS x64 | 无 | 本版本不提供 Intel 构建 |
|
||||
| Windows | 无 | 当前不支持 |
|
||||
|
||||
正式支持以对应 GitHub Release 的 `release-manifest.json` 为准。Manifest 记录版本、
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "one-memory",
|
||||
"version": "1.0.5",
|
||||
"release": {
|
||||
"build_number": 35,
|
||||
"build_number": 36,
|
||||
"macos_min_version": "13.0",
|
||||
"app_update": "manual",
|
||||
"runtime_manifest_schema": 1,
|
||||
|
||||
@@ -7,7 +7,10 @@ export const applicationsChineseMessages = {
|
||||
"apps.back": "返回首页",
|
||||
"apps.overview": "记忆概览",
|
||||
"apps.memories": "已沉淀记忆",
|
||||
"apps.readingCount": "正在读取已沉淀记忆数量…",
|
||||
"apps.readingCount": "正在核对本机记忆…",
|
||||
"apps.startingRetry": "后台正在启动,稍后自动更新",
|
||||
"apps.countWillUpdate": "记忆数量稍后自动更新",
|
||||
"apps.showingPrevious": "正在显示上次数据,稍后自动更新",
|
||||
"apps.unreadable": "暂无法读取",
|
||||
"apps.unit": "条",
|
||||
"apps.scope": "按应用授权范围使用",
|
||||
@@ -16,9 +19,12 @@ export const applicationsChineseMessages = {
|
||||
"apps.paused": "已暂停",
|
||||
"apps.stoppedNew": "已停止生成新记忆",
|
||||
"apps.checkingProcessing": "正在核对整理状态",
|
||||
"apps.checkingStatus": "正在核对",
|
||||
"apps.unchecked": "待核对",
|
||||
"apps.organizingNew": "新的对话正在整理",
|
||||
"apps.recentUpdate": "最近更新",
|
||||
"apps.checkingRecent": "正在核对",
|
||||
"apps.recentWillUpdate": "最近更新会自动显示",
|
||||
"apps.notGenerated": "尚未生成",
|
||||
"apps.saved": "记忆已保存",
|
||||
"apps.separate": "历史与记忆分别处理",
|
||||
@@ -26,14 +32,14 @@ export const applicationsChineseMessages = {
|
||||
"apps.activityUnavailable": "动态暂不可用",
|
||||
"apps.firstBatch": "首批沉淀",
|
||||
"apps.callsUnavailable": "暂时无法读取调用记录",
|
||||
"apps.memoryUnavailable": "暂时无法读取记忆状态",
|
||||
"apps.memoryUnavailable": "记忆概览还没准备好",
|
||||
"apps.importing": "对话持续导入中,即将沉淀你的记忆",
|
||||
"apps.firstRunning": "正在沉淀第一批记忆",
|
||||
"apps.firstQueued": "准备沉淀第一批记忆",
|
||||
"apps.readingActivity": "正在读取动态",
|
||||
"apps.readingActivity": "正在准备记忆概览",
|
||||
"apps.emptyMemories": "还没有已生成的记忆",
|
||||
"apps.callReadFailure": "已有记忆仍可查看;重试不会发起新的调用。",
|
||||
"apps.memoryReadFailure": "读取失败不会当作没有记忆。",
|
||||
"apps.memoryReadFailure": "后台恢复后会自动更新,也可以重新读取。",
|
||||
"apps.savingConversations": "对话正在后台持续保存。",
|
||||
"apps.waitingMemories": "历史已保存,记忆生成后会显示在这里。",
|
||||
"apps.reread": "重新读取",
|
||||
@@ -121,7 +127,12 @@ export const applicationsSpanishMessages: Record<
|
||||
"apps.back": "Volver al inicio",
|
||||
"apps.overview": "Resumen de memorias",
|
||||
"apps.memories": "Memorias guardadas",
|
||||
"apps.readingCount": "Leyendo el total de memorias…",
|
||||
"apps.readingCount": "Comprobando las memorias locales…",
|
||||
"apps.startingRetry":
|
||||
"El servicio se está iniciando; se actualizará automáticamente",
|
||||
"apps.countWillUpdate": "El total de memorias se actualizará automáticamente",
|
||||
"apps.showingPrevious":
|
||||
"Se muestran los datos anteriores; se actualizarán automáticamente",
|
||||
"apps.unreadable": "No disponible",
|
||||
"apps.unit": "",
|
||||
"apps.scope": "Según los permisos de cada aplicación",
|
||||
@@ -130,9 +141,12 @@ export const applicationsSpanishMessages: Record<
|
||||
"apps.paused": "En pausa",
|
||||
"apps.stoppedNew": "Se dejó de generar memorias nuevas",
|
||||
"apps.checkingProcessing": "Comprobando el estado del procesamiento",
|
||||
"apps.checkingStatus": "Verificando",
|
||||
"apps.unchecked": "Sin verificar",
|
||||
"apps.organizingNew": "Organizando nuevas conversaciones",
|
||||
"apps.recentUpdate": "Última actualización",
|
||||
"apps.checkingRecent": "Verificando",
|
||||
"apps.recentWillUpdate": "La última actualización aparecerá automáticamente",
|
||||
"apps.notGenerated": "Sin generar",
|
||||
"apps.saved": "Memoria guardada",
|
||||
"apps.separate": "Historial y memorias se procesan por separado",
|
||||
@@ -140,16 +154,16 @@ export const applicationsSpanishMessages: Record<
|
||||
"apps.activityUnavailable": "Actividad no disponible",
|
||||
"apps.firstBatch": "Primeras memorias",
|
||||
"apps.callsUnavailable": "No se pueden leer las consultas",
|
||||
"apps.memoryUnavailable": "No se puede leer el estado de las memorias",
|
||||
"apps.memoryUnavailable": "El resumen de memorias aún no está listo",
|
||||
"apps.importing": "Importando conversaciones para generar memorias",
|
||||
"apps.firstRunning": "Generando las primeras memorias",
|
||||
"apps.firstQueued": "Preparando las primeras memorias",
|
||||
"apps.readingActivity": "Leyendo actividad",
|
||||
"apps.readingActivity": "Preparando el resumen de memorias",
|
||||
"apps.emptyMemories": "Aún no hay memorias generadas",
|
||||
"apps.callReadFailure":
|
||||
"Puedes ver las memorias guardadas. Reintentar no inicia otra consulta.",
|
||||
"apps.memoryReadFailure":
|
||||
"Un error de lectura no significa que no haya memorias.",
|
||||
"Se actualizará cuando el servicio se recupere. También puedes volver a leerlo.",
|
||||
"apps.savingConversations": "Las conversaciones se guardan en segundo plano.",
|
||||
"apps.waitingMemories":
|
||||
"El historial está guardado. Las memorias aparecerán cuando se generen.",
|
||||
|
||||
@@ -1305,7 +1305,12 @@ const baseEnglishMessages = {
|
||||
"apps.back": "Back to home",
|
||||
"apps.overview": "Memory overview",
|
||||
"apps.memories": "Saved memories",
|
||||
"apps.readingCount": "Loading memory count…",
|
||||
"apps.readingCount": "Checking local memories…",
|
||||
"apps.startingRetry":
|
||||
"The background service is starting; this will update automatically",
|
||||
"apps.countWillUpdate": "The memory count will update automatically",
|
||||
"apps.showingPrevious":
|
||||
"Showing the previous data; this will update automatically",
|
||||
"apps.unreadable": "Unavailable",
|
||||
"apps.unit": "items",
|
||||
"apps.scope": "Used within app permissions",
|
||||
@@ -1314,9 +1319,12 @@ const baseEnglishMessages = {
|
||||
"apps.paused": "Paused",
|
||||
"apps.stoppedNew": "No new memories are being created",
|
||||
"apps.checkingProcessing": "Checking processing status",
|
||||
"apps.checkingStatus": "Checking",
|
||||
"apps.unchecked": "Needs review",
|
||||
"apps.organizingNew": "Processing new conversations",
|
||||
"apps.recentUpdate": "Recent update",
|
||||
"apps.checkingRecent": "Checking",
|
||||
"apps.recentWillUpdate": "The latest update will appear automatically",
|
||||
"apps.notGenerated": "Not created yet",
|
||||
"apps.saved": "Memory saved",
|
||||
"apps.separate": "History and memories are processed separately",
|
||||
@@ -1324,16 +1332,16 @@ const baseEnglishMessages = {
|
||||
"apps.activityUnavailable": "Activity unavailable",
|
||||
"apps.firstBatch": "First memories",
|
||||
"apps.callsUnavailable": "Could not read call history",
|
||||
"apps.memoryUnavailable": "Could not read memory status",
|
||||
"apps.memoryUnavailable": "The memory overview is not ready yet",
|
||||
"apps.importing": "Importing conversations before creating memories",
|
||||
"apps.firstRunning": "Creating the first memories",
|
||||
"apps.firstQueued": "Preparing the first memories",
|
||||
"apps.readingActivity": "Loading activity",
|
||||
"apps.readingActivity": "Preparing memory overview",
|
||||
"apps.emptyMemories": "No memories created yet",
|
||||
"apps.callReadFailure":
|
||||
"You can still view saved memories. Retrying will not make a new call.",
|
||||
"apps.memoryReadFailure":
|
||||
"A failed read does not mean there are no memories.",
|
||||
"This will update when the service recovers. You can also retry now.",
|
||||
"apps.savingConversations":
|
||||
"Conversations are still being saved in the background.",
|
||||
"apps.waitingMemories": "History is saved. New memories will appear here.",
|
||||
@@ -1613,6 +1621,7 @@ const baseEnglishMessages = {
|
||||
"Newly imported original text, not yet memories",
|
||||
"memory:来源归属": "Source connections",
|
||||
"memory:可在沉淀记忆后查看视图": "Create memories to view the graph",
|
||||
"memory:正在读取记忆地图…": "Loading memory map…",
|
||||
"memory:查看选中来源原文": "View selected source text",
|
||||
"memory:查看选中记忆原文": "View selected memory text",
|
||||
"memory:查看原文": "View original conversation",
|
||||
|
||||
@@ -187,6 +187,7 @@ export const memoryChineseMessages = {
|
||||
"memory:刚导入的原话,还不是沉淀后的记忆": "刚导入的原话,还不是沉淀后的记忆",
|
||||
"memory:来源归属": "来源归属",
|
||||
"memory:可在沉淀记忆后查看视图": "可在沉淀记忆后查看视图",
|
||||
"memory:正在读取记忆地图…": "正在读取记忆地图…",
|
||||
"memory:查看选中来源原文": "查看选中来源原文",
|
||||
"memory:查看选中记忆原文": "查看选中记忆原文",
|
||||
"memory:查看原文": "查看原文",
|
||||
@@ -589,6 +590,7 @@ export const memorySpanishMessages: Record<
|
||||
"memory:来源归属": "Relación con el origen",
|
||||
"memory:可在沉淀记忆后查看视图":
|
||||
"La vista estará disponible cuando se generen memorias",
|
||||
"memory:正在读取记忆地图…": "Leyendo el mapa de memorias…",
|
||||
"memory:查看选中来源原文": "Ver texto del origen seleccionado",
|
||||
"memory:查看选中记忆原文": "Ver texto de la memoria seleccionada",
|
||||
"memory:查看原文": "Ver texto original",
|
||||
|
||||
@@ -25,7 +25,8 @@ test("installation docs disclose publication and lifecycle boundaries", async ()
|
||||
assert.match(installation, /Linux x64.*GitHub Actions/s);
|
||||
assert.match(installation, /Linux arm64.*GitHub Actions/s);
|
||||
assert.match(installation, /macOS arm64.*Developer ID/s);
|
||||
assert.match(installation, /macOS x64.*Developer ID/s);
|
||||
// v1.0.5 ships no Intel build; the table has to say so rather than omit it.
|
||||
assert.match(installation, /\| macOS x64 \| 无 \|/);
|
||||
assert.match(installation, /release-manifest\.json/);
|
||||
assert.match(installation, /fake Release/);
|
||||
assert.match(installation, /--source/);
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hy-memory"
|
||||
version = "2.0.8"
|
||||
version = "2.0.9"
|
||||
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""P0-1:占位符不得污染 L0 基础画像。
|
||||
|
||||
弱模型(qwen3.5-2b)在用户什么都没说时把 basic_info 填成 {"name": "用户"} /
|
||||
"The user" / "N/A",每个占位符都 supersede 掉真实值,演化链变成
|
||||
费天翔 → Fei → 用户 → The user。防御:(a) upsert 占位符守卫 + 读侧愈合;
|
||||
(c) prompt 硬约束。验收 2(assistant 引用 "name is 用户")由占位符守卫单独兜住。
|
||||
本文件覆盖改进建议第 10 节验收 1–4。
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory.agent.extractor import EXTRACT_FEW_SHOT_EN
|
||||
from hy_memory.agent.prompts_zh import EXTRACT_FEW_SHOT_ZH
|
||||
from hy_memory.agent.tools.basic_profile import (
|
||||
_assemble_full_kv_from_nodes,
|
||||
is_placeholder_value,
|
||||
upsert_basic_profile,
|
||||
)
|
||||
from hy_memory.models.memory import MemoryLayer, MemoryNode, MemoryStatus
|
||||
|
||||
FIELDS = ["name", "age", "location", "occupation", "employer"]
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, nodes=None):
|
||||
self.nodes = list(nodes or [])
|
||||
|
||||
async def list_by_user(self, **kwargs):
|
||||
return [n for n in self.nodes if n.user_id == kwargs.get("user_id")]
|
||||
|
||||
async def get_by_id(self, node_id):
|
||||
return next((n for n in self.nodes if n.node_id == node_id), None)
|
||||
|
||||
async def upsert(self, node):
|
||||
self.nodes.append(node)
|
||||
|
||||
async def update_payload(self, node_id, updates):
|
||||
node = await self.get_by_id(node_id)
|
||||
for key, value in updates.items():
|
||||
if key == "status" and isinstance(value, str):
|
||||
value = MemoryStatus(value)
|
||||
setattr(node, key, value)
|
||||
|
||||
|
||||
class FakeEmbed:
|
||||
async def embed_queued(self, text):
|
||||
return [0.1] * 8
|
||||
|
||||
|
||||
def l0_versions(store):
|
||||
return [n for n in store.nodes if n.layer == MemoryLayer.L0_BASIC_INFO]
|
||||
|
||||
|
||||
def write_basic_info(store, llm_basic_info):
|
||||
"""模拟 writer 的 basic_info 段:LLM 给出的非空 basic_info 直接 upsert。"""
|
||||
if not llm_basic_info:
|
||||
return None
|
||||
return asyncio.run(upsert_basic_profile(
|
||||
user_id="u1", kv=llm_basic_info, vector_store=store,
|
||||
embed_service=FakeEmbed(), allowed_fields=FIELDS,
|
||||
))
|
||||
|
||||
|
||||
# ── 验收 1:寒暄不产生新的 L0 版本 ──
|
||||
|
||||
def test_small_talk_creates_no_l0_version():
|
||||
# [user]: 你好 / [assistant]: 我可以帮你。弱模型输出 {"name": "用户"}
|
||||
store = FakeStore()
|
||||
write_basic_info(store, {"name": "用户"})
|
||||
write_basic_info(store, {})
|
||||
assert l0_versions(store) == []
|
||||
|
||||
|
||||
def test_placeholder_only_upsert_writes_nothing():
|
||||
store = FakeStore()
|
||||
for kv in ({"name": "用户"}, {"name": "The user"}, {"name": "N/A"},
|
||||
{"location": "unknown", "occupation": "未提供"}, {"name": "name"}):
|
||||
res = write_basic_info(store, kv)
|
||||
assert res.success and not res.changed
|
||||
assert l0_versions(store) == []
|
||||
|
||||
|
||||
# ── 验收 2:assistant 引用 "name is 用户" 不写入(仅靠占位符守卫)──
|
||||
|
||||
def test_assistant_quoting_placeholder_does_not_write():
|
||||
# [assistant]: 那条 The user's name is 用户. 更说明问题
|
||||
store = FakeStore()
|
||||
for kv in ({"name": "用户"}, {"name": "The user"}, {"name": "The user."}):
|
||||
res = write_basic_info(store, kv)
|
||||
assert res.success and not res.changed
|
||||
assert l0_versions(store) == []
|
||||
|
||||
|
||||
# ── 验收 3:用户自报姓名 ──
|
||||
|
||||
def test_user_states_name():
|
||||
# [user]: 我叫费天翔,常住杭州,做 AI 基础设施。
|
||||
store = FakeStore()
|
||||
res = write_basic_info(store, {
|
||||
"name": "费天翔", "location": "杭州", "occupation": "AI 基础设施从业者",
|
||||
})
|
||||
assert res.success and res.created
|
||||
assert l0_versions(store)[0].custom["basic_info_kv"] == {
|
||||
"name": "费天翔", "location": "杭州", "occupation": "AI 基础设施从业者",
|
||||
}
|
||||
|
||||
|
||||
# ── 验收 4:已有真实值不被后续占位符覆盖 ──
|
||||
|
||||
def test_existing_name_not_overwritten_by_placeholder():
|
||||
store = FakeStore()
|
||||
write_basic_info(store, {"name": "费天翔"})
|
||||
assert len(l0_versions(store)) == 1
|
||||
for kv in ({"name": "用户"}, {"name": "The user."}, {"name": "N/A"}):
|
||||
res = write_basic_info(store, kv)
|
||||
assert not res.changed
|
||||
assert len(l0_versions(store)) == 1
|
||||
assert _assemble_full_kv_from_nodes(list(store.nodes)) == {"name": "费天翔"}
|
||||
|
||||
|
||||
def test_mixed_kv_keeps_real_fields_and_drops_placeholders():
|
||||
store = FakeStore()
|
||||
res = write_basic_info(store, {"name": "用户", "location": "杭州"})
|
||||
assert res.diff_kv == {"location": "杭州"}
|
||||
|
||||
|
||||
def test_already_poisoned_chain_heals_on_read():
|
||||
now = datetime.utcnow()
|
||||
|
||||
def node(nid, kv, days_ago):
|
||||
return MemoryNode(
|
||||
node_id=nid, user_id="u1", agent_id="",
|
||||
layer=MemoryLayer.L0_BASIC_INFO, content="profile",
|
||||
custom={"basic_info_kv": kv},
|
||||
gmt_created=now - timedelta(days=days_ago),
|
||||
)
|
||||
|
||||
chain = [
|
||||
node("a", {"name": "费天翔"}, 4), node("b", {"name": "Fei"}, 3),
|
||||
node("c", {"name": "用户"}, 2), node("d", {"name": "The user"}, 1),
|
||||
]
|
||||
assert _assemble_full_kv_from_nodes(chain) == {"name": "Fei"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
"", " ", "用户", "用户。", "The user", "the user.", "THE USER", "user",
|
||||
"The user's", "n/a", "N/A", "na", "None", "null", "unknown", "未提供",
|
||||
"未知", "不详", "-", "?", "name", " Name ", "姓名",
|
||||
])
|
||||
def test_placeholder_values(value):
|
||||
assert is_placeholder_value("name", value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [
|
||||
("name", "费天翔"), ("name", "Fei"), ("name", "Username42"),
|
||||
("age", 30), ("age", 0), ("location", "杭州"), ("employer", "Tencent"),
|
||||
])
|
||||
def test_real_values_are_not_placeholders(field, value):
|
||||
assert not is_placeholder_value(field, value)
|
||||
|
||||
|
||||
# ── (c) prompt 硬约束 ──
|
||||
|
||||
def test_prompts_carry_hard_rule_and_small_talk_counter_example():
|
||||
from hy_memory.agent import extractor, prompts_zh
|
||||
|
||||
en_rules = [v for v in vars(extractor).values()
|
||||
if isinstance(v, str) and "HARD RULE: fill a field ONLY" in v]
|
||||
zh_rules = [v for v in vars(prompts_zh).values()
|
||||
if isinstance(v, str) and "**硬性规则**" in v]
|
||||
assert len(en_rules) >= 2 and len(zh_rules) >= 2
|
||||
assert "small talk has no basic_info" in EXTRACT_FEW_SHOT_EN
|
||||
assert "寒暄没有 basic_info" in EXTRACT_FEW_SHOT_ZH
|
||||
Reference in new issue
Block a user