Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2e51a8cc | ||
|
|
d4fca0639e | ||
|
|
f7961f340a | ||
|
|
cfebae118d | ||
|
|
3e8a006f65 | ||
|
|
a70830970d | ||
|
|
556d95df84 | ||
|
|
e09fbd16f9 |
No files matched your search
@@ -68,6 +68,22 @@ 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.7 — 2026-10-28
|
||||
|
||||
- Coding and working experiences and facts now keep their conversation time.
|
||||
`add(..., memory_at=...)` reached only the chat path before; the
|
||||
productivity path dropped it, so every experience had only its write time
|
||||
and MemHub showed them as "时间未知" (time unknown). `coding_memory_meta`
|
||||
and `working_fact` gain a nullable `memory_at` column (epoch seconds) in
|
||||
both the working and coding stores; existing databases get it on open, and
|
||||
their old rows stay `None`. Without `memory_at` a write uses the time the
|
||||
request arrived, as chat does. When an experience is updated by a later
|
||||
conversation it keeps the later of the two times.
|
||||
- `list_memories` (`scene="coding"`, `"working"` and `"all"`), `get` and
|
||||
`search` return `memory_at` for experiences and facts in the same field and
|
||||
format as chat (epoch seconds, `None` when unknown). Experience search
|
||||
results now also carry `created_at`.
|
||||
|
||||
## Hy Memory 2.0.6 — 2026-10-27
|
||||
|
||||
- Published
|
||||
|
||||
+8
-1
@@ -1544,6 +1544,7 @@ class HyMemoryClient:
|
||||
# LLM 写的"这个结论一路怎么改成现在这样",直接进提示词;
|
||||
# 没被覆盖过就是空串。不参与检索——召回认的是 statement 的当前值。
|
||||
"fact_chain_summary": fact.fact_chain_summary,
|
||||
"memory_at": _epoch_seconds(fact.memory_at),
|
||||
"created_at": fact.created_at.isoformat() if fact.created_at else None,
|
||||
"score": round(float(score), 4),
|
||||
"source": fact.source,
|
||||
@@ -1652,6 +1653,8 @@ class HyMemoryClient:
|
||||
"score": (round(float(score_by_id[d.experience_id]), 4)
|
||||
if d.experience_id in score_by_id else None),
|
||||
"confidence": d.confidence,
|
||||
"memory_at": _epoch_seconds(d.memory_at),
|
||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
||||
"source": d.source,
|
||||
"extraction_source": d.extraction_source,
|
||||
@@ -1953,6 +1956,7 @@ class HyMemoryClient:
|
||||
"content" chat 正文;经验为 "task\nsolution";事实为 statement
|
||||
"score" RRF 融合分;"lane_score" 为该路原始分;"lane_rank" 为该路名次
|
||||
"source", "created_at", "updated_at"(秒级 epoch,取不到为 None)
|
||||
"memory_at" 对话时间(秒级 epoch),各路原有字段,三路同名同格式
|
||||
chat 路内部名次:normal → schema → proactive 依次排;working/coding 路
|
||||
内部名次:事实与经验各按原顺序交替(事实在前),两层谁也不压谁。
|
||||
"""
|
||||
@@ -2144,7 +2148,8 @@ class HyMemoryClient:
|
||||
"limit", "offset", "request_id", "elapsed_ms"}
|
||||
每条带 "scene"、"kind"(chat | experience)、"memory_id"、"content"
|
||||
(经验为 "task\nsolution")、"source"、"status"、"created_at"、
|
||||
"updated_at"(秒级 epoch),以及该路原有字段。
|
||||
"updated_at"(秒级 epoch),以及该路原有字段(含对话时间
|
||||
"memory_at",秒级 epoch,三路同名同格式)。
|
||||
"""
|
||||
return self._loop_thread.run(
|
||||
self.async_list_memories(
|
||||
@@ -2967,6 +2972,7 @@ class HyMemoryClient:
|
||||
session_id=session_id,
|
||||
request_id=request_id,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
)
|
||||
# Public receipt always identifies the productivity namespace.
|
||||
resp["variant"] = scene_variant
|
||||
@@ -5920,6 +5926,7 @@ class HyMemoryClient:
|
||||
"source_fact_ids": list(d.source_fact_ids),
|
||||
"confidence": d.confidence,
|
||||
"status": MemoryStatus.ACTIVE.value,
|
||||
"memory_at": _epoch_seconds(d.memory_at),
|
||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||
"updated_at": d.updated_at.isoformat() if d.updated_at else None,
|
||||
"source": d.source,
|
||||
|
||||
@@ -454,9 +454,10 @@ class WorkingFactReconciler:
|
||||
workspace_id: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||
"""
|
||||
执行 reconcile + 持久化。
|
||||
执行 reconcile + 持久化。memory_at 是这段对话的时间,记到新事实上。
|
||||
|
||||
Returns:
|
||||
(真正落库的新事实, 执行过的 ops)。LLM 挂掉时退化为全 INSERT 不覆盖,
|
||||
@@ -493,6 +494,7 @@ class WorkingFactReconciler:
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
)
|
||||
logger.info(
|
||||
f"[fact-reconcile] drafts={len(drafts)} inserted={len(facts)} "
|
||||
@@ -781,6 +783,7 @@ class WorkingFactReconciler:
|
||||
workspace_id: Optional[str],
|
||||
branch: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
) -> Tuple[List[WorkingFact], List[FactReconcileOp]]:
|
||||
inserted: List[WorkingFact] = []
|
||||
ops: List[FactReconcileOp] = []
|
||||
@@ -801,6 +804,7 @@ class WorkingFactReconciler:
|
||||
subject=draft.subject,
|
||||
details=draft.details,
|
||||
status="active",
|
||||
memory_at=memory_at,
|
||||
)
|
||||
|
||||
allowed = {c.fact_id for c in (candidates[idx] if idx < len(candidates) else [])}
|
||||
|
||||
@@ -577,6 +577,7 @@ class CodingMemoryReconciler:
|
||||
confidence=d.confidence,
|
||||
source=d.source,
|
||||
extraction_source=d.extraction_source,
|
||||
memory_at=d.memory_at,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS coding_memory_meta (
|
||||
confidence REAL NOT NULL DEFAULT 0.7,
|
||||
source TEXT NOT NULL DEFAULT 'default',
|
||||
extraction_source TEXT NOT NULL DEFAULT 'auto_extract',
|
||||
memory_at INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -109,6 +110,7 @@ CREATE TABLE IF NOT EXISTS working_fact (
|
||||
derived_experience_ids TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
fact_chain_summary TEXT NOT NULL DEFAULT '',
|
||||
memory_at INTEGER,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
-- 反查前驱(谁的 fact_link 指向我)走这个索引,写演变总结时要顺着链往回走
|
||||
@@ -164,6 +166,8 @@ _ADDED_COLUMNS = {
|
||||
"coding_memory_meta": {
|
||||
"source_fact_ids": "TEXT NOT NULL DEFAULT '[]'",
|
||||
"extraction_source": "TEXT NOT NULL DEFAULT 'auto_extract'",
|
||||
# 对话时间,秒级 epoch(同 chat)。老库里的行是 NULL:那时没存过。
|
||||
"memory_at": "INTEGER",
|
||||
},
|
||||
# 早期版本存过一列 fact_chain(机械累积的历代说法),已废弃:同样的信息顺着
|
||||
# fact_link 反查就有。老库里那一列留着不管,有默认值不影响插入。
|
||||
@@ -177,6 +181,7 @@ _ADDED_COLUMNS = {
|
||||
"subject": "TEXT NOT NULL DEFAULT ''",
|
||||
"details": "TEXT NOT NULL DEFAULT ''",
|
||||
"source": "TEXT NOT NULL DEFAULT 'default'",
|
||||
"memory_at": "INTEGER",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -290,6 +295,29 @@ def _parse_dt(s: Optional[str]) -> Optional[datetime]:
|
||||
return None
|
||||
|
||||
|
||||
def _from_epoch(value: Any) -> Optional[datetime]:
|
||||
"""memory_at 列(秒级 epoch)→ datetime;NULL 或坏值返回 None。"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(int(value))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _to_epoch(value: Optional[datetime]) -> Optional[int]:
|
||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
||||
|
||||
|
||||
def _later(a: Optional[datetime], b: Optional[datetime]) -> Optional[datetime]:
|
||||
"""两个对话时间取较晚的;一边缺就取另一边。"""
|
||||
if a is None:
|
||||
return b
|
||||
if b is None:
|
||||
return a
|
||||
return a if _to_epoch(a) >= _to_epoch(b) else b
|
||||
|
||||
|
||||
def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
||||
"""SQLite row → CodingMemory"""
|
||||
return CodingMemory(
|
||||
@@ -308,6 +336,7 @@ def _row_to_memory(row: sqlite3.Row) -> CodingMemory:
|
||||
confidence=row["confidence"],
|
||||
source=row["source"],
|
||||
extraction_source=row["extraction_source"],
|
||||
memory_at=_from_epoch(row["memory_at"]),
|
||||
created_at=_parse_dt(row["created_at"]),
|
||||
updated_at=_parse_dt(row["updated_at"]),
|
||||
)
|
||||
@@ -334,6 +363,7 @@ def _row_to_fact(row: sqlite3.Row) -> WorkingFact:
|
||||
# 老库刚 ADD COLUMN 时可能取不到这一列,用 keys() 兜一下
|
||||
fact_chain_summary=(row["fact_chain_summary"] or ""
|
||||
if "fact_chain_summary" in row.keys() else ""),
|
||||
memory_at=_from_epoch(row["memory_at"]),
|
||||
created_at=_parse_dt(row["created_at"]),
|
||||
)
|
||||
|
||||
@@ -597,6 +627,8 @@ class CodingMemoryStore:
|
||||
memory.updated_at = datetime.utcnow()
|
||||
if memory.created_at is None:
|
||||
memory.created_at = prev.created_at
|
||||
# 合并后的经验讲的是两段对话,对话时间取较晚的那段
|
||||
memory.memory_at = _later(prev.memory_at, memory.memory_at)
|
||||
await _run_in_pool(self._upsert_sql_sync, memory)
|
||||
# 删旧 VDB
|
||||
await self._delete_vec_keys_by_count(memory.experience_id, prev_key_count)
|
||||
@@ -1199,8 +1231,8 @@ class CodingMemoryStore:
|
||||
experience_id, user_id, agent_id, task, search_keys, solution,
|
||||
boundary_envs, workspace_id, branch, session_id,
|
||||
files, source_fact_ids, confidence, source, extraction_source,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
memory_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(experience_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
agent_id = excluded.agent_id,
|
||||
@@ -1216,6 +1248,7 @@ class CodingMemoryStore:
|
||||
confidence = excluded.confidence,
|
||||
source = excluded.source,
|
||||
extraction_source = excluded.extraction_source,
|
||||
memory_at = excluded.memory_at,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
@@ -1226,6 +1259,7 @@ class CodingMemoryStore:
|
||||
json.dumps(memory.files, ensure_ascii=False),
|
||||
json.dumps(memory.source_fact_ids, ensure_ascii=False),
|
||||
memory.confidence, memory.source, memory.extraction_source,
|
||||
_to_epoch(memory.memory_at),
|
||||
(memory.created_at or datetime.utcnow()).isoformat(),
|
||||
(memory.updated_at or datetime.utcnow()).isoformat(),
|
||||
),
|
||||
@@ -1366,8 +1400,8 @@ class CodingMemoryStore:
|
||||
fact_id, user_id, agent_id, session_id, workspace_id, branch,
|
||||
source, statement, files, subject, details,
|
||||
fact_link, derived_experience_ids,
|
||||
status, fact_chain_summary, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
status, fact_chain_summary, memory_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(fact_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
agent_id = excluded.agent_id,
|
||||
@@ -1382,7 +1416,8 @@ class CodingMemoryStore:
|
||||
fact_link = excluded.fact_link,
|
||||
derived_experience_ids = excluded.derived_experience_ids,
|
||||
status = excluded.status,
|
||||
fact_chain_summary = excluded.fact_chain_summary
|
||||
fact_chain_summary = excluded.fact_chain_summary,
|
||||
memory_at = excluded.memory_at
|
||||
""",
|
||||
(
|
||||
fact.fact_id, fact.user_id, fact.agent_id,
|
||||
@@ -1395,6 +1430,7 @@ class CodingMemoryStore:
|
||||
json.dumps(fact.derived_experience_ids, ensure_ascii=False),
|
||||
fact.status,
|
||||
fact.fact_chain_summary or "",
|
||||
_to_epoch(fact.memory_at),
|
||||
(fact.created_at or datetime.utcnow()).isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -25,6 +25,11 @@ FactStatus = Literal["active", "superseded"]
|
||||
MAX_FACT_CHAIN_ENTRIES = 8
|
||||
|
||||
|
||||
def _epoch(value: Optional[datetime]) -> Optional[int]:
|
||||
"""memory_at 对外是秒级 epoch,与 chat 一致。"""
|
||||
return int(value.timestamp()) if isinstance(value, datetime) else None
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 草稿(LLM extract 阶段输出)
|
||||
# ================================================================
|
||||
@@ -53,6 +58,8 @@ class CodingMemoryDraft:
|
||||
files: List[str] = field(default_factory=list)
|
||||
source: str = "default"
|
||||
extraction_source: str = "auto_extract"
|
||||
# 对话时间(SDK 注入,同 chat 的 memory_at)
|
||||
memory_at: Optional[datetime] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
from ..utils.source_filter import normalize_source
|
||||
@@ -73,6 +80,7 @@ class CodingMemoryDraft:
|
||||
"files": list(self.files),
|
||||
"source": self.source,
|
||||
"extraction_source": self.extraction_source,
|
||||
"memory_at": _epoch(self.memory_at),
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +119,9 @@ class CodingMemory:
|
||||
extraction_source: str = "auto_extract"
|
||||
|
||||
# 时间
|
||||
# memory_at 是对话时间(同 chat),created_at / updated_at 是写库时间。
|
||||
# UPDATE 合并时取两者中较晚的那个对话时间,见 store.update。
|
||||
memory_at: Optional[datetime] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
@@ -135,6 +146,7 @@ class CodingMemory:
|
||||
"confidence": self.confidence,
|
||||
"source": self.source,
|
||||
"extraction_source": self.extraction_source,
|
||||
"memory_at": _epoch(self.memory_at),
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -249,6 +261,7 @@ class WorkingFact:
|
||||
fact_chain_summary: str = ""
|
||||
|
||||
# 时间
|
||||
memory_at: Optional[datetime] = None # 对话时间(同 chat),没传时是收到请求的时间
|
||||
created_at: Optional[datetime] = None # 产生时间,新的优先
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -272,6 +285,7 @@ class WorkingFact:
|
||||
"derived_experience_ids": list(self.derived_experience_ids),
|
||||
"status": self.status,
|
||||
"fact_chain_summary": self.fact_chain_summary,
|
||||
"memory_at": _epoch(self.memory_at),
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ CodingWriter - 写入端协调器
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..agent.llm_provider import LLMProvider
|
||||
@@ -79,9 +80,11 @@ class CodingWriter:
|
||||
session_id: Optional[str] = None,
|
||||
request_id: Optional[str] = None,
|
||||
source: str = "default",
|
||||
memory_at: Optional[datetime] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
working/coding 段写入端到端。返回响应 dict(兼容 client.add 返回结构):
|
||||
working/coding 段写入端到端。memory_at 是对话时间,记到这次写出的经验和
|
||||
事实上;不传则用收到请求的时间(与 chat 链路一致)。返回响应 dict(兼容 client.add 返回结构):
|
||||
{
|
||||
"success": True,
|
||||
"scene": "productivity",
|
||||
@@ -101,6 +104,8 @@ class CodingWriter:
|
||||
"""
|
||||
request_id = request_id or str(uuid.uuid4())
|
||||
t0 = time.perf_counter()
|
||||
if memory_at is None:
|
||||
memory_at = datetime.now()
|
||||
|
||||
if not messages:
|
||||
return self._empty_response(request_id, t0)
|
||||
@@ -172,6 +177,7 @@ class CodingWriter:
|
||||
# extractor 只负责内容;调用来源由 SDK 入口注入,不能交给 LLM 生成。
|
||||
for draft in drafts:
|
||||
draft.source = source
|
||||
draft.memory_at = memory_at
|
||||
|
||||
# 3. 经验层 reconcile + 持久化
|
||||
ops: List[ReconcileOp] = (
|
||||
@@ -197,6 +203,7 @@ class CodingWriter:
|
||||
workspace_id=workspace_id,
|
||||
branch=branch,
|
||||
source=source,
|
||||
memory_at=memory_at,
|
||||
)
|
||||
|
||||
experience_ids = [op.target_experience_id for op in ops
|
||||
|
||||
@@ -28,11 +28,15 @@ describe("ConnectionFallback", () => {
|
||||
} as unknown as Window["oneMemory"];
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<ConnectionFallback />
|
||||
<ConnectionFallback defaultExpanded />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByText("Conectar otras herramientas"));
|
||||
expect(
|
||||
(await screen.findByText("Conectar otras herramientas")).closest(
|
||||
"details",
|
||||
),
|
||||
).toHaveProperty("open", true);
|
||||
await user.type(
|
||||
screen.getByLabelText("Nombre del Harness"),
|
||||
"Harness 原名",
|
||||
@@ -59,8 +63,11 @@ describe("ConnectionFallback", () => {
|
||||
createCustomHarnessSkill,
|
||||
copyText,
|
||||
} as unknown as Window["oneMemory"];
|
||||
render(<ConnectionFallback />);
|
||||
await user.click(screen.getByText("连接其他工具"));
|
||||
render(<ConnectionFallback defaultExpanded />);
|
||||
expect(screen.getByText("连接其他工具").closest("details")).toHaveProperty(
|
||||
"open",
|
||||
true,
|
||||
);
|
||||
const generate = screen.getByRole("button", { name: "生成配对 Skill" });
|
||||
expect(generate).toHaveProperty("disabled", true);
|
||||
await user.type(screen.getByLabelText("Harness 名称"), "Kimi Code");
|
||||
@@ -90,8 +97,7 @@ describe("ConnectionFallback", () => {
|
||||
createCustomHarnessSkill,
|
||||
copyText: vi.fn().mockRejectedValue(new Error()),
|
||||
} as unknown as Window["oneMemory"];
|
||||
render(<ConnectionFallback />);
|
||||
await user.click(screen.getByText("连接其他工具"));
|
||||
render(<ConnectionFallback defaultExpanded />);
|
||||
await user.type(screen.getByLabelText("Harness 名称"), "Kimi Code");
|
||||
await user.click(screen.getByRole("button", { name: "生成配对 Skill" }));
|
||||
await user.click(await screen.findByRole("button", { name: "重试" }));
|
||||
@@ -103,6 +109,10 @@ describe("ConnectionFallback", () => {
|
||||
});
|
||||
it("does not invent runtime paths in the browser", () => {
|
||||
render(<ConnectionFallback />);
|
||||
expect(screen.getByText("连接其他工具").closest("details")).toHaveProperty(
|
||||
"open",
|
||||
false,
|
||||
);
|
||||
expect(screen.queryByLabelText("配对 Skill")).toBeNull();
|
||||
expect(screen.getByText("本机配对仅在桌面 App 中可用。")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -13,8 +13,13 @@ type MaterialBridge = {
|
||||
getConnectionMaterial(): Promise<ConnectionMaterial>;
|
||||
};
|
||||
|
||||
export function ConnectionFallback() {
|
||||
export function ConnectionFallback({
|
||||
defaultExpanded = false,
|
||||
}: {
|
||||
defaultExpanded?: boolean;
|
||||
} = {}) {
|
||||
const { locale, t } = useGuiLanguage();
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [material, setMaterial] = useState<Material | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -42,7 +47,11 @@ export function ConnectionFallback() {
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="connection-fallback">
|
||||
<details
|
||||
className="connection-fallback"
|
||||
open={expanded}
|
||||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||||
>
|
||||
<summary>{t("connectionFallback.title")}</summary>
|
||||
{materialBridge?.getConnectionMaterial ? (
|
||||
<ConnectionMaterialFallback
|
||||
|
||||
@@ -15,7 +15,7 @@ afterEach(() => {
|
||||
|
||||
it.each(["zh-CN", "en", "es"] as const)(
|
||||
"hides the compact label while preserving its accessible name in %s",
|
||||
(locale) => {
|
||||
async (locale) => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, locale);
|
||||
render(
|
||||
<LanguageProvider>
|
||||
@@ -28,6 +28,7 @@ it.each(["zh-CN", "en", "es"] as const)(
|
||||
expect(label.hidden).toBe(true);
|
||||
expect(label.htmlFor).toBe(selector.id);
|
||||
expect(screen.getByRole("region", { name })).toBeTruthy();
|
||||
await userEvent.click(selector);
|
||||
expect(screen.getByRole("option", { name: "Español" })).toBeTruthy();
|
||||
expect(screen.getByRole("option", { name: "English" })).toBeTruthy();
|
||||
expect(screen.getByRole("option", { name: "简体中文" })).toBeTruthy();
|
||||
@@ -60,18 +61,19 @@ it.each(["zh-CN", "en", "es"] as const)(
|
||||
|
||||
it.each(["system", "zh-CN", "en", "es"] as const)(
|
||||
"keeps one stable settings row for the %s preference",
|
||||
(preference) => {
|
||||
async (preference) => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, preference);
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
const selector = screen.getByRole("combobox") as HTMLButtonElement;
|
||||
const row = selector.closest(".r6-language-setting-row");
|
||||
expect(selector.value).toBe(preference);
|
||||
expect(row).toBeTruthy();
|
||||
expect(row?.querySelector("label")?.hidden).toBe(false);
|
||||
await userEvent.click(selector);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("option", {
|
||||
@@ -102,9 +104,10 @@ it("offers an accessible selector, persists and updates its label", async () =>
|
||||
const selector = screen.getByRole("combobox", {
|
||||
name: translate("zh-CN", "languageLabel"),
|
||||
});
|
||||
expect(screen.getAllByRole("option")).toHaveLength(4);
|
||||
expect(selector.getAttribute("aria-describedby")).toBeTruthy();
|
||||
await userEvent.selectOptions(selector, "es");
|
||||
await userEvent.click(selector);
|
||||
expect(screen.getAllByRole("option")).toHaveLength(4);
|
||||
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
|
||||
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("es");
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: translate("es", "languageLabel") }),
|
||||
@@ -112,6 +115,31 @@ it("offers an accessible selector, persists and updates its label", async () =>
|
||||
expect(document.documentElement.lang).toBe("es");
|
||||
});
|
||||
|
||||
it("supports keyboard selection without using the system select menu", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "system");
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<LanguageSelector />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
const selector = screen.getByRole("combobox");
|
||||
selector.focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("true");
|
||||
await userEvent.tab();
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
selector.focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
await userEvent.keyboard(" ");
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("true");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await userEvent.keyboard("{ArrowDown}{ArrowDown}{Enter}");
|
||||
expect(localStorage.getItem(LANGUAGE_STORAGE_KEY)).toBe("zh-CN");
|
||||
expect(selector.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("announces storage errors without replacing the confirmed choice", async () => {
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, "zh-CN");
|
||||
render(
|
||||
@@ -122,11 +150,12 @@ it("announces storage errors without replacing the confirmed choice", async () =
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("denied");
|
||||
});
|
||||
await userEvent.selectOptions(screen.getByRole("combobox"), "es");
|
||||
await userEvent.click(screen.getByRole("combobox"));
|
||||
await userEvent.click(screen.getByRole("option", { name: "西班牙语" }));
|
||||
expect(screen.getByRole("alert").textContent).toBe(
|
||||
translate("zh-CN", "languageSaveError"),
|
||||
);
|
||||
expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe(
|
||||
expect((screen.getByRole("combobox") as HTMLButtonElement).value).toBe(
|
||||
"zh-CN",
|
||||
);
|
||||
});
|
||||
@@ -1,11 +1,30 @@
|
||||
import { isLanguagePreference } from "@one-memory/i18n";
|
||||
import { useId, useState } from "react";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
|
||||
export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
const id = useId();
|
||||
const { preference, setPreference, t, error } = useGuiLanguage();
|
||||
const [pending, setPending] = useState(false);
|
||||
const options = [
|
||||
{ value: "system", label: t("languageSystem") },
|
||||
{
|
||||
value: "zh-CN",
|
||||
label: compact ? "简体中文" : t("languageChinese"),
|
||||
lang: "zh-CN",
|
||||
},
|
||||
{
|
||||
value: "en",
|
||||
label: compact ? "English" : t("languageEnglish"),
|
||||
lang: "en",
|
||||
},
|
||||
{
|
||||
value: "es",
|
||||
label: compact ? "Español" : t("languageSpanish"),
|
||||
lang: "es",
|
||||
},
|
||||
] as const;
|
||||
return (
|
||||
<section
|
||||
aria-labelledby={compact ? `${id}-label` : `${id}-title`}
|
||||
@@ -26,14 +45,16 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
<label id={`${id}-label`} htmlFor={`${id}-select`} hidden={compact}>
|
||||
{t(compact ? "languageTitle" : "languageLabel")}
|
||||
</label>
|
||||
<select
|
||||
<OptionMenu
|
||||
id={`${id}-select`}
|
||||
aria-label={compact ? t("languageTitle") : undefined}
|
||||
aria-describedby={compact ? undefined : `${id}-description`}
|
||||
ariaLabel={compact ? t("languageTitle") : undefined}
|
||||
labelledBy={compact ? undefined : `${id}-label`}
|
||||
describedBy={compact ? undefined : `${id}-description`}
|
||||
value={preference}
|
||||
disabled={pending}
|
||||
onChange={async (event) => {
|
||||
const value = event.currentTarget.value;
|
||||
options={options}
|
||||
className="r6-language-option-menu"
|
||||
onChange={async (value) => {
|
||||
if (!isLanguagePreference(value)) return;
|
||||
setPending(true);
|
||||
try {
|
||||
@@ -42,18 +63,7 @@ export function LanguageSelector({ compact = false }: { compact?: boolean }) {
|
||||
setPending(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="system">{t("languageSystem")}</option>
|
||||
<option value="zh-CN" lang="zh-CN">
|
||||
{compact ? "简体中文" : t("languageChinese")}
|
||||
</option>
|
||||
<option value="en" lang="en">
|
||||
{compact ? "English" : t("languageEnglish")}
|
||||
</option>
|
||||
<option value="es" lang="es">
|
||||
{compact ? "Español" : t("languageSpanish")}
|
||||
</option>
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
{!compact && (
|
||||
<p id={`${id}-description`} className="r6-language-setting-description">
|
||||
|
||||
@@ -212,6 +212,7 @@ it("searches only when 搜索 is pressed, not on every keystroke", async () => {
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("combobox", { name: "来源" })).toBeTruthy();
|
||||
await userEvent.type(
|
||||
screen.getByRole("textbox", { name: "筛选地图记忆" }),
|
||||
"版本号",
|
||||
@@ -265,6 +266,29 @@ it("OBS-15: switching memory shows a read in progress instead of the previous me
|
||||
expect(screen.getByRole("status").textContent).toContain("正在读取这条记忆");
|
||||
});
|
||||
|
||||
it("keeps the detail header outside the scrolling body and resets detail scroll on selection", async () => {
|
||||
const { container } = render(
|
||||
<Host simplified sources={[importedSources[0]]} />,
|
||||
);
|
||||
const heading = screen
|
||||
.getByRole("heading", { name: "记忆详情" })
|
||||
.closest(".map-inspector-heading");
|
||||
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 = 180;
|
||||
await userEvent.click(
|
||||
within(screen.getByRole("list", { name: "地图记忆列表" })).getByRole(
|
||||
"button",
|
||||
{ name: /第二条/ },
|
||||
),
|
||||
);
|
||||
expect(body.scrollTop).toBe(0);
|
||||
body.scrollTop = 120;
|
||||
await userEvent.click(screen.getByRole("button", { name: "图中选择 Codex" }));
|
||||
expect(body.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("OBS-12: the graph legend stays with the graph instead of sitting under a stage without one", () => {
|
||||
// While the first page is still being read there is no graph yet: the
|
||||
// legend used to sit there anyway, describing nothing.
|
||||
@@ -546,6 +570,73 @@ it("D96: lists a memory by when its conversation happened, not when it was gener
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps pagination and current detail stable while the next page is loading", async () => {
|
||||
const onPage = vi.fn();
|
||||
const page = {
|
||||
items: memories,
|
||||
total: 202,
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
source_ids_by_memory: { m0: ["codex"], m1: ["codex"] },
|
||||
};
|
||||
const libraryView = { ...pagedView(page), onPage };
|
||||
const view = render(<Host simplified libraryView={libraryView} />);
|
||||
const list = screen.getByRole("list", { name: "地图记忆列表" });
|
||||
list.scrollTop = 160;
|
||||
const pager = screen.getByRole("navigation", { name: "地图记忆分页" });
|
||||
await userEvent.click(
|
||||
within(pager).getByRole("button", { name: "下一页记忆" }),
|
||||
);
|
||||
expect(onPage).toHaveBeenCalledWith(100);
|
||||
view.rerender(
|
||||
<Host simplified libraryView={{ ...libraryView, loading: true }} />,
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByRole("navigation", { name: "地图记忆分页" })
|
||||
.getAttribute("aria-busy"),
|
||||
).toBe("true");
|
||||
expect(
|
||||
(
|
||||
screen.getByRole("button", {
|
||||
name: "下一页记忆",
|
||||
}) as HTMLButtonElement
|
||||
).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.getByRole("heading", { name: "第一条" })).toBeTruthy();
|
||||
expect(list.scrollTop).toBe(160);
|
||||
|
||||
const nextItems = memories.map((memory, index) => ({
|
||||
...memory,
|
||||
memory_id: `next-${index}`,
|
||||
content: `下一页第 ${index + 1} 条`,
|
||||
}));
|
||||
view.rerender(
|
||||
<Host
|
||||
simplified
|
||||
items={nextItems}
|
||||
libraryView={{
|
||||
...libraryView,
|
||||
page: {
|
||||
...page,
|
||||
items: nextItems,
|
||||
offset: 100,
|
||||
source_ids_by_memory: {
|
||||
"next-0": ["codex"],
|
||||
"next-1": ["codex"],
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(list.scrollTop).toBe(0);
|
||||
expect(within(list).getByText("下一页第 1 条")).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("heading", { name: "第一条" })).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("names the SDK's no-source bucket 未识别来源, apart from a source under review", () => {
|
||||
const items = ["有来源", "无来源"].map((content, i) => ({
|
||||
...memories[0],
|
||||
|
||||
+157
-129
@@ -25,6 +25,7 @@ import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -220,6 +221,8 @@ export function MemoryMap(props: Props) {
|
||||
const compact = useMediaPreference("(max-width: 1100px)");
|
||||
const composing = useRef(false);
|
||||
const pane = useRef<HTMLElement>(null);
|
||||
const memoryList = useRef<HTMLUListElement>(null);
|
||||
const inspectorBody = useRef<HTMLDivElement>(null);
|
||||
const openedDefault = useRef(false);
|
||||
const scrollSelection = useRef(false);
|
||||
const controlledSource = library
|
||||
@@ -367,6 +370,11 @@ export function MemoryMap(props: Props) {
|
||||
const activeId = active.some((memory) => memory.memory_id === requestedId)
|
||||
? requestedId
|
||||
: null;
|
||||
const inspectorIdentity = activeId
|
||||
? `memory:${activeId}`
|
||||
: selectedSource
|
||||
? `source:${selectedSource.source_id}`
|
||||
: (visibleGroupNode?.node_id ?? "empty");
|
||||
const visibleDetail =
|
||||
detail &&
|
||||
(!activeId || activeId === detail.memory.memory_id) &&
|
||||
@@ -427,6 +435,19 @@ export function MemoryMap(props: Props) {
|
||||
container.scrollTop += itemBounds.bottom - containerBounds.bottom;
|
||||
}
|
||||
}, [activeId]);
|
||||
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 (inspectorBody.current) inspectorBody.current.scrollTop = 0;
|
||||
}, [inspectorIdentity]);
|
||||
const settledPageOffset = library?.page?.offset;
|
||||
useLayoutEffect(() => {
|
||||
// A settled page is a new reading surface, not a continuation of the
|
||||
// previous page's scroll position.
|
||||
if (settledPageOffset === undefined) return;
|
||||
if (memoryList.current) memoryList.current.scrollTop = 0;
|
||||
}, [settledPageOffset]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setSelectedId(null);
|
||||
@@ -586,6 +607,7 @@ export function MemoryMap(props: Props) {
|
||||
<label htmlFor="selected-source-filter">{text("来源")}</label>
|
||||
<OptionMenu
|
||||
id="selected-source-filter"
|
||||
ariaLabel={text("来源")}
|
||||
className="om-option-menu--plain"
|
||||
value={controlledSource}
|
||||
options={[
|
||||
@@ -781,6 +803,7 @@ export function MemoryMap(props: Props) {
|
||||
</p>
|
||||
)}
|
||||
<ul
|
||||
ref={memoryList}
|
||||
className="map-memory-list"
|
||||
aria-label={text("地图记忆列表")}
|
||||
id="memory-pane-memories"
|
||||
@@ -874,18 +897,21 @@ export function MemoryMap(props: Props) {
|
||||
</p>
|
||||
)}
|
||||
{library?.page &&
|
||||
!library.loading &&
|
||||
(!library.loading || library.page.total > 0) &&
|
||||
!library.failed &&
|
||||
!library.stale && (
|
||||
<nav aria-label={text("地图记忆分页")} className="map-pagination">
|
||||
<nav
|
||||
aria-label={text("地图记忆分页")}
|
||||
aria-busy={library.loading || undefined}
|
||||
className="map-pagination"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="quiet map-tool"
|
||||
aria-label={text("上一页记忆")}
|
||||
title={text("上一页记忆")}
|
||||
disabled={library.page.offset === 0}
|
||||
disabled={library.loading || library.page.offset === 0}
|
||||
onClick={() => {
|
||||
clear();
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page)
|
||||
library.onPage(library.page.offset - library.page.limit);
|
||||
@@ -914,10 +940,10 @@ export function MemoryMap(props: Props) {
|
||||
aria-label={text("下一页记忆")}
|
||||
title={text("下一页记忆")}
|
||||
disabled={
|
||||
library.loading ||
|
||||
library.page.offset + library.page.limit >= library.page.total
|
||||
}
|
||||
onClick={() => {
|
||||
clear();
|
||||
if (axis === "topic") setGroupKey("");
|
||||
if (library.page)
|
||||
library.onPage(library.page.offset + library.page.limit);
|
||||
@@ -1117,142 +1143,144 @@ export function MemoryMap(props: Props) {
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
{selectedSource ? (
|
||||
inspectorOpen &&
|
||||
!overlaysSuspended && (
|
||||
<SourceImportReceipt
|
||||
key={selectedSource.source_id}
|
||||
source={selectedSource}
|
||||
/>
|
||||
)
|
||||
) : props.simplified && inspectorDetail ? (
|
||||
<div
|
||||
aria-busy={
|
||||
refreshing ||
|
||||
inspectorDetail.memory.memory_id !== activeId ||
|
||||
undefined
|
||||
}
|
||||
onClickCapture={(event) => {
|
||||
// React portal events follow this owner too, including editor saves.
|
||||
if (
|
||||
refreshing &&
|
||||
(event.target as Element).closest("button, summary")
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
<div className="map-inspector-body" ref={inspectorBody}>
|
||||
{selectedSource ? (
|
||||
inspectorOpen &&
|
||||
!overlaysSuspended && (
|
||||
<SourceImportReceipt
|
||||
key={selectedSource.source_id}
|
||||
source={selectedSource}
|
||||
/>
|
||||
)
|
||||
) : props.simplified && inspectorDetail ? (
|
||||
<div
|
||||
aria-busy={
|
||||
refreshing ||
|
||||
inspectorDetail.memory.memory_id !== activeId ||
|
||||
undefined
|
||||
}
|
||||
}}
|
||||
onSubmitCapture={(event) => {
|
||||
if (refreshing) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* The pane used to keep the previously read memory on screen
|
||||
onClickCapture={(event) => {
|
||||
// React portal events follow this owner too, including editor saves.
|
||||
if (
|
||||
refreshing &&
|
||||
(event.target as Element).closest("button, summary")
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onSubmitCapture={(event) => {
|
||||
if (refreshing) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* The pane used to keep the previously read memory on screen
|
||||
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 ? (
|
||||
<div className="map-detail-loading">
|
||||
{activeId && inspectorDetail.memory.memory_id !== activeId ? (
|
||||
<div className="map-detail-loading">
|
||||
<div className="map-detail-skeleton" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<p role="status">{text("正在读取这条记忆…")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<SelectedMemoryDetail
|
||||
detail={inspectorDetail}
|
||||
sources={sources}
|
||||
conversationAt={
|
||||
library?.page?.source_time_by_memory?.[
|
||||
inspectorDetail.memory.memory_id
|
||||
] ?? null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : inspectorDetail || visibleGroupNode ? (
|
||||
<NodeDetail
|
||||
key={
|
||||
inspectorDetail?.memory.memory_id ||
|
||||
visibleGroupNode?.node_id ||
|
||||
"empty"
|
||||
}
|
||||
compact
|
||||
node={visibleGroupNode}
|
||||
graph={projection}
|
||||
detail={inspectorDetail}
|
||||
draft={props.draft}
|
||||
busy={busy}
|
||||
matterLabel={label}
|
||||
onDraft={props.onDraft}
|
||||
onSave={props.onSave}
|
||||
onDelete={props.onDelete}
|
||||
requestIds={props.requestIds}
|
||||
onClose={closeInspector}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`map-inspector-empty${activeId ? " map-inspector-loading" : ""}`}
|
||||
>
|
||||
{activeId ? (
|
||||
<div className="map-detail-skeleton" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
<p role="status">{text("正在读取这条记忆…")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<SelectedMemoryDetail
|
||||
detail={inspectorDetail}
|
||||
sources={sources}
|
||||
conversationAt={
|
||||
library?.page?.source_time_by_memory?.[
|
||||
inspectorDetail.memory.memory_id
|
||||
] ?? null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : inspectorDetail || visibleGroupNode ? (
|
||||
<NodeDetail
|
||||
key={
|
||||
inspectorDetail?.memory.memory_id ||
|
||||
visibleGroupNode?.node_id ||
|
||||
"empty"
|
||||
}
|
||||
compact
|
||||
node={visibleGroupNode}
|
||||
graph={projection}
|
||||
detail={inspectorDetail}
|
||||
draft={props.draft}
|
||||
busy={busy}
|
||||
matterLabel={label}
|
||||
onDraft={props.onDraft}
|
||||
onSave={props.onSave}
|
||||
onDelete={props.onDelete}
|
||||
requestIds={props.requestIds}
|
||||
onClose={closeInspector}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`map-inspector-empty${activeId ? " map-inspector-loading" : ""}`}
|
||||
>
|
||||
{activeId ? (
|
||||
<div className="map-detail-skeleton" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</div>
|
||||
) : (
|
||||
<FileText size={28} strokeWidth={1} />
|
||||
)}
|
||||
{props.detailLoad?.memoryId === activeId &&
|
||||
props.detailLoad.status === "failed" ? (
|
||||
<>
|
||||
<p role="alert">
|
||||
{text("这条记忆的原文暂时无法读取,选择已保留。")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => activeId && onOpenMemory(activeId)}
|
||||
>
|
||||
{text("重试读取原文")}
|
||||
</button>
|
||||
</>
|
||||
) : props.simplified && !activeId && filtered.length ? (
|
||||
// Nothing is selected, but this conversation has already
|
||||
// settled: naming what came out of it beats a placeholder that
|
||||
// asks for the settle that already happened. Only the
|
||||
// single-column surface needs it — the three-column one already
|
||||
// lists the same memories in the pane beside this one.
|
||||
<>
|
||||
) : (
|
||||
<FileText size={28} strokeWidth={1} />
|
||||
)}
|
||||
{props.detailLoad?.memoryId === activeId &&
|
||||
props.detailLoad.status === "failed" ? (
|
||||
<>
|
||||
<p role="alert">
|
||||
{text("这条记忆的原文暂时无法读取,选择已保留。")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => activeId && onOpenMemory(activeId)}
|
||||
>
|
||||
{text("重试读取原文")}
|
||||
</button>
|
||||
</>
|
||||
) : props.simplified && !activeId && filtered.length ? (
|
||||
// Nothing is selected, but this conversation has already
|
||||
// settled: naming what came out of it beats a placeholder that
|
||||
// asks for the settle that already happened. Only the
|
||||
// single-column surface needs it — the three-column one already
|
||||
// lists the same memories in the pane beside this one.
|
||||
<>
|
||||
<p>
|
||||
{text(
|
||||
"已沉淀 {count} 条记忆",
|
||||
{ count: plain(filtered.length) },
|
||||
filtered.length,
|
||||
)}
|
||||
</p>
|
||||
<ul
|
||||
className="map-settled-list"
|
||||
aria-label={text("已沉淀的记忆")}
|
||||
>
|
||||
{filtered.map((m) => (
|
||||
<li key={m.memory_id}>{m.content}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{text(
|
||||
"已沉淀 {count} 条记忆",
|
||||
{ count: plain(filtered.length) },
|
||||
filtered.length,
|
||||
)}
|
||||
{text(activeId ? "正在读取记忆详情…" : "沉淀记忆后查看详情")}
|
||||
</p>
|
||||
<ul
|
||||
className="map-settled-list"
|
||||
aria-label={text("已沉淀的记忆")}
|
||||
>
|
||||
{filtered.map((m) => (
|
||||
<li key={m.memory_id}>{m.content}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
{text(activeId ? "正在读取记忆详情…" : "沉淀记忆后查看详情")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MemoryEvidencePanel>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -201,8 +201,9 @@ it("keeps an added memory visible while it waits for model processing", async ()
|
||||
render(<MemorySpace simplified onConfigureModel={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "补充记忆" }));
|
||||
expect(
|
||||
screen.getByText(/原话已保存,正在等待整理:项目周会统一安排在周三上午。/),
|
||||
screen.getByText(/正在等待整理:项目周会统一安排在周三上午。/),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/原话已保存/)).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "配置整理模型" })).toBeTruthy();
|
||||
expect(screen.queryByText("还没有已生成的记忆")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -953,7 +953,7 @@ export function MemorySpace({
|
||||
{simplified && pendingAdd ? (
|
||||
<p className="memory-pending-add" role="status">
|
||||
<span>
|
||||
{t("memory:原话已保存,正在等待整理:")}
|
||||
{t("memory:正在等待整理:")}
|
||||
{pendingAdd.text}
|
||||
</span>
|
||||
{pendingAdd.state === "waiting_for_model" ? (
|
||||
|
||||
@@ -117,7 +117,8 @@ it("changes the first-screen language without losing source choices or starting
|
||||
),
|
||||
);
|
||||
await user.click(screen.getByLabelText("导入 Cursor 历史"));
|
||||
await user.selectOptions(selector, "es");
|
||||
await user.click(selector);
|
||||
await user.click(screen.getByRole("option", { name: "Español" }));
|
||||
expect(screen.getByRole("combobox", { name: "Idioma de la interfaz" })).toBe(
|
||||
selector,
|
||||
);
|
||||
@@ -125,7 +126,8 @@ it("changes the first-screen language without losing source choices or starting
|
||||
screen.getByLabelText("Importar el historial de Cursor"),
|
||||
).toHaveProperty("checked", false);
|
||||
await user.click(screen.getByLabelText("Importar el historial de Cursor"));
|
||||
await user.selectOptions(selector, "zh-CN");
|
||||
await user.click(selector);
|
||||
await user.click(screen.getByRole("option", { name: "简体中文" }));
|
||||
expect(screen.getByLabelText("导入 Cursor 历史")).toHaveProperty(
|
||||
"checked",
|
||||
true,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useGuiLanguage } from "./gui-language";
|
||||
|
||||
export interface OptionMenuItem {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
/** Replaces a native select so the open list follows the product field, not the OS menu. */
|
||||
@@ -20,11 +21,50 @@ export function OptionMenu(props: {
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useGuiLanguage();
|
||||
const generatedId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const selectedIndex = props.options.findIndex(
|
||||
(item) => item.value === props.value,
|
||||
);
|
||||
const firstEnabledIndex = props.options.findIndex((item) => !item.disabled);
|
||||
const preferredIndex =
|
||||
selectedIndex >= 0 && !props.options[selectedIndex]?.disabled
|
||||
? selectedIndex
|
||||
: firstEnabledIndex;
|
||||
const [activeIndex, setActiveIndex] = useState(preferredIndex);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
const selected = props.options.find((item) => item.value === props.value);
|
||||
const visibleLabel = selected?.label ?? t("wb.select");
|
||||
const controlId = props.id ?? `${generatedId}-control`;
|
||||
const panelId = `${controlId}-options`;
|
||||
|
||||
function enabledIndex(from: number, direction: 1 | -1) {
|
||||
if (firstEnabledIndex < 0) return -1;
|
||||
let next = from;
|
||||
for (let checked = 0; checked < props.options.length; checked += 1) {
|
||||
next =
|
||||
next < 0
|
||||
? direction === 1
|
||||
? 0
|
||||
: props.options.length - 1
|
||||
: (next + direction + props.options.length) % props.options.length;
|
||||
if (!props.options[next]?.disabled) return next;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function choose(index: number) {
|
||||
const item = props.options[index];
|
||||
if (!item || item.disabled) return;
|
||||
props.onChange(item.value);
|
||||
setOpen(false);
|
||||
button.current?.focus();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setActiveIndex(preferredIndex);
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!root.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
@@ -37,47 +77,98 @@ export function OptionMenu(props: {
|
||||
document.removeEventListener("mousedown", close);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
}, [open, preferredIndex]);
|
||||
return (
|
||||
<div
|
||||
className={["om-option-menu", props.className].filter(Boolean).join(" ")}
|
||||
ref={root}
|
||||
>
|
||||
<button
|
||||
ref={button}
|
||||
type="button"
|
||||
id={props.id}
|
||||
id={controlId}
|
||||
value={props.value}
|
||||
className="om-option-menu-button"
|
||||
role="combobox"
|
||||
aria-labelledby={props.labelledBy}
|
||||
aria-label={props.ariaLabel}
|
||||
aria-label={
|
||||
props.ariaLabel ?? (props.labelledBy ? undefined : t("wb.select"))
|
||||
}
|
||||
aria-describedby={props.describedBy}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
aria-activedescendant={
|
||||
open && activeIndex >= 0
|
||||
? `${panelId}-option-${activeIndex}`
|
||||
: undefined
|
||||
}
|
||||
disabled={props.disabled}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onBlur={(event) => {
|
||||
if (!root.current?.contains(event.relatedTarget as Node | null))
|
||||
setOpen(false);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (preferredIndex < 0) return;
|
||||
setActiveIndex(preferredIndex);
|
||||
setOpen((current) => !current);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "Enter") {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
if (!open) {
|
||||
if (preferredIndex >= 0) {
|
||||
setActiveIndex(preferredIndex);
|
||||
setOpen(true);
|
||||
}
|
||||
} else {
|
||||
setActiveIndex((current) => enabledIndex(current, 1));
|
||||
}
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
if (preferredIndex >= 0) {
|
||||
setActiveIndex(preferredIndex);
|
||||
setOpen(true);
|
||||
}
|
||||
} else {
|
||||
setActiveIndex((current) => enabledIndex(current, -1));
|
||||
}
|
||||
} else if (event.key === "Home" && open) {
|
||||
event.preventDefault();
|
||||
setActiveIndex(enabledIndex(-1, 1));
|
||||
} else if (event.key === "End" && open) {
|
||||
event.preventDefault();
|
||||
setActiveIndex(enabledIndex(0, -1));
|
||||
} else if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (open) choose(activeIndex);
|
||||
else if (preferredIndex >= 0) {
|
||||
setActiveIndex(preferredIndex);
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selected?.label ?? t("wb.select")}
|
||||
{visibleLabel}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="om-option-menu-panel" role="listbox">
|
||||
{props.options.map((item) => (
|
||||
<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}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
props.onChange(item.value);
|
||||
setOpen(false);
|
||||
tabIndex={-1}
|
||||
onMouseEnter={() => {
|
||||
if (!item.disabled) setActiveIndex(index);
|
||||
}}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
|
||||
@@ -99,7 +99,7 @@ it("keeps selection status outside decorative fades and readable when disabled",
|
||||
).toMatch(/opacity:\s*1/);
|
||||
});
|
||||
|
||||
it("keeps the native selector keyboard-focusable and switches both languages", async () => {
|
||||
it("keeps the product language menu keyboard-focusable and switches both languages", async () => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<R6ColdStartFrame
|
||||
@@ -116,17 +116,19 @@ it("keeps the native selector keyboard-focusable and switches both languages", a
|
||||
const selector = screen.getByRole("combobox");
|
||||
await user.tab();
|
||||
expect(document.activeElement).toBe(selector);
|
||||
await user.selectOptions(selector, "es");
|
||||
await user.click(selector);
|
||||
await user.click(screen.getByRole("option", { name: "Español" }));
|
||||
expect(document.documentElement.lang).toBe("es");
|
||||
expect(screen.getByRole("combobox").getAttribute("id")).toBe(selector.id);
|
||||
await user.selectOptions(selector, "zh-CN");
|
||||
await user.click(selector);
|
||||
await user.click(screen.getByRole("option", { name: "简体中文" }));
|
||||
expect(document.documentElement.lang).toBe("zh-CN");
|
||||
expect(screen.getByRole("combobox", { name: "界面语言" })).toBe(selector);
|
||||
});
|
||||
|
||||
it.each([1, 2, 3] as const)(
|
||||
"offers a compact language selector on step %s",
|
||||
(step) => {
|
||||
async (step) => {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<R6ColdStartFrame
|
||||
@@ -141,6 +143,7 @@ it.each([1, 2, 3] as const)(
|
||||
);
|
||||
const selector = screen.getByRole("combobox", { name: "界面语言" });
|
||||
expect(selector.closest(".r6-language-selector--compact")).toBeTruthy();
|
||||
await userEvent.click(selector);
|
||||
expect(
|
||||
screen.getByRole("option", { name: "简体中文" }).getAttribute("lang"),
|
||||
).toBe("zh-CN");
|
||||
|
||||
@@ -27,7 +27,8 @@ it("changes settings copy live without rereading profiles or changing permission
|
||||
}),
|
||||
).toBeNull();
|
||||
const reads = vi.mocked(getModelProfiles).mock.calls.length;
|
||||
await user.selectOptions(screen.getByRole("combobox"), "zh-CN");
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
await user.click(screen.getByRole("option", { name: "Chino simplificado" }));
|
||||
expect(await screen.findByRole("heading", { name: "记忆模型" })).toBeTruthy();
|
||||
expect(vi.mocked(getModelProfiles).mock.calls).toHaveLength(reads);
|
||||
});
|
||||
@@ -66,7 +67,9 @@ vi.mock("./ModelSettings", async () => {
|
||||
};
|
||||
});
|
||||
vi.mock("./ConnectionFallback", () => ({
|
||||
ConnectionFallback: () => null,
|
||||
ConnectionFallback: ({ defaultExpanded }: { defaultExpanded?: boolean }) => (
|
||||
<p data-testid="connection-default">{String(defaultExpanded)}</p>
|
||||
),
|
||||
}));
|
||||
vi.mock("./runtime-state", () => ({
|
||||
refreshLoginPreference: vi.fn(),
|
||||
@@ -95,7 +98,11 @@ it("shows the help and diagnostics group by default and hides it when opted out"
|
||||
const user = userEvent.setup();
|
||||
const view = render(<R6Settings onDiagnostics={vi.fn()} />);
|
||||
expect(screen.getByRole("combobox")).toBeTruthy();
|
||||
await user.click(screen.getByRole("combobox"));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(4);
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "连接其他应用" }));
|
||||
expect(screen.getByTestId("connection-default").textContent).toBe("true");
|
||||
await user.click(screen.getByRole("button", { name: "帮助与诊断" }));
|
||||
expect(screen.getByRole("heading", { name: "帮助与诊断" })).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "导出日志" })).toBeTruthy();
|
||||
|
||||
@@ -401,7 +401,7 @@ export function R6Settings({
|
||||
className="r6-settings-connections"
|
||||
>
|
||||
<h2>{text("连接其他应用")}</h2>
|
||||
<ConnectionFallback />
|
||||
<ConnectionFallback defaultExpanded />
|
||||
</section>
|
||||
{showDiagnostics && (
|
||||
<section
|
||||
|
||||
@@ -14,16 +14,20 @@ import { LANGUAGE_STORAGE_KEY, LanguageProvider } from "./gui-language";
|
||||
import { OptionMenu } from "./OptionMenu";
|
||||
import { Answer, NodeDetail } from "./Workbench";
|
||||
|
||||
it("localiza la selección vacía del menú auxiliar", () => {
|
||||
it("localiza y mantiene cerrado el menú auxiliar sin opciones", async () => {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, "es");
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<OptionMenu value="" options={[]} onChange={vi.fn()} />
|
||||
</LanguageProvider>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Selecciona una opción" }),
|
||||
).toBeTruthy();
|
||||
const menu = screen.getByRole("combobox", {
|
||||
name: "Selecciona una opción",
|
||||
});
|
||||
menu.focus();
|
||||
await userEvent.keyboard("{Enter}{ArrowDown}");
|
||||
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(menu.getAttribute("aria-activedescendant")).toBeNull();
|
||||
window.localStorage.removeItem(LANGUAGE_STORAGE_KEY);
|
||||
});
|
||||
|
||||
|
||||
@@ -1086,8 +1086,9 @@ button.map-tool {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.map-inspector {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 24px;
|
||||
background: var(--glass-drawer);
|
||||
border-left: 1px solid var(--bd);
|
||||
@@ -1095,10 +1096,19 @@ button.map-tool {
|
||||
}
|
||||
.map-inspector-heading {
|
||||
display: flex;
|
||||
flex: none;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.map-inspector-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.map-inspector-heading h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -55,18 +55,29 @@
|
||||
gap: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.onboarding-r6-titlebar select {
|
||||
.onboarding-r6-titlebar .r6-language-option-menu {
|
||||
width: 144px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button {
|
||||
height: 32px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 4px 8px;
|
||||
padding: 4px 32px 4px 10px;
|
||||
border: 1px solid #9badbd;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
background: #f7fafc;
|
||||
font: inherit;
|
||||
}
|
||||
.onboarding-r6-titlebar select:focus-visible {
|
||||
.onboarding-r6-titlebar .r6-language-option-menu .om-option-menu-button::after {
|
||||
top: 10px;
|
||||
right: 11px;
|
||||
}
|
||||
.onboarding-r6-titlebar
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-button:focus-visible {
|
||||
outline: 2px solid #356d99;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/* R6 source 139:5728 / 6765 / 7176 / 7585. Overrides stay inside settings. */
|
||||
.r6-settings {
|
||||
box-sizing: border-box;
|
||||
padding: 26px 40px 46px;
|
||||
color: #262a2e;
|
||||
padding: 30px 40px 48px;
|
||||
color: #304757;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
@@ -10,161 +10,230 @@
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.r6-settings > h1 {
|
||||
height: 44px;
|
||||
height: auto;
|
||||
font:
|
||||
500 28px / 44px "Noto Sans SC",
|
||||
600 26px / 36px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
margin: 0 0 28px;
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
.r6-settings .r6-language-setting {
|
||||
margin-bottom: 24px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid #dce4eb;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #d5dfe8;
|
||||
border-radius: 12px;
|
||||
background: rgb(255 255 255 / 78%);
|
||||
}
|
||||
.r6-language-setting-title {
|
||||
margin: 0 0 18px;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
margin: 0 0 14px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
}
|
||||
.r6-settings .r6-language-setting-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
grid-template-columns: 104px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.r6-settings .r6-language-setting-row label {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: #4f6373;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.r6-settings .r6-language-setting select {
|
||||
box-sizing: border-box;
|
||||
.r6-settings .r6-language-option-menu {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dce4eb;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
}
|
||||
.r6-settings .r6-language-setting select:focus-visible {
|
||||
outline: 2px solid #225f9d;
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-button {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 42px 0 14px;
|
||||
border: 1px solid #c6d2da;
|
||||
border-radius: 8px;
|
||||
color: #304757;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
}
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-button::after {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-button[aria-expanded="true"] {
|
||||
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);
|
||||
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"] {
|
||||
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"] {
|
||||
background: #dfe9ef;
|
||||
font-weight: 500;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"][data-active="true"] {
|
||||
color: #304757;
|
||||
background: #e9f0f4;
|
||||
}
|
||||
.r6-settings
|
||||
.r6-language-option-menu
|
||||
.om-option-menu-panel
|
||||
[role="option"]:active:not(:disabled) {
|
||||
color: #304757;
|
||||
background: #d4e2ea;
|
||||
}
|
||||
.r6-settings .r6-language-option-menu .om-option-menu-button:focus-visible {
|
||||
outline: 2px solid #557c93;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.r6-language-setting-description {
|
||||
margin: 16px 0 0;
|
||||
margin: 12px 0 0 120px;
|
||||
color: #5e6e7a;
|
||||
font-size: 13px;
|
||||
line-height: 22px;
|
||||
line-height: 21px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-settings .r6-settings-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
grid-template-columns: 190px minmax(0, 1fr);
|
||||
gap: 0;
|
||||
align-items: stretch;
|
||||
border: 1px solid #d5dfe8;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
.r6-settings .r6-settings-layout > nav {
|
||||
box-sizing: border-box;
|
||||
min-height: 710px;
|
||||
padding: 13px 11px;
|
||||
background: rgb(255 255 255 / 27.45%);
|
||||
border: 1px solid white;
|
||||
border-radius: 16px;
|
||||
box-shadow: inset 0 1px white;
|
||||
backdrop-filter: blur(72px);
|
||||
min-height: 560px;
|
||||
padding: 16px 12px;
|
||||
background: #f3f7f9;
|
||||
border: 0;
|
||||
border-right: 1px solid #d5dfe8;
|
||||
border-radius: 11px 0 0 11px;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
gap: 4px;
|
||||
}
|
||||
.r6-settings .r6-settings-layout > nav > button {
|
||||
box-sizing: border-box;
|
||||
padding: 8px 16px;
|
||||
padding: 8px 12px;
|
||||
height: auto;
|
||||
min-height: 46px;
|
||||
min-height: 42px;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #262a2e;
|
||||
color: #405665;
|
||||
font:
|
||||
400 16px / 28px "Noto Sans SC",
|
||||
400 14px / 22px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 140ms ease-out,
|
||||
background-color 140ms ease-out;
|
||||
}
|
||||
.r6-settings .r6-settings-layout > nav > button[aria-current="page"] {
|
||||
background: #e1eaf0;
|
||||
color: #304757;
|
||||
background: #dfe9ef;
|
||||
font-weight: 500;
|
||||
}
|
||||
.r6-settings .r6-settings-layout > nav > button:hover:not(:disabled) {
|
||||
background: rgb(225 234 240 / 65%);
|
||||
color: #304757;
|
||||
background: #e9f0f4;
|
||||
transform: none;
|
||||
}
|
||||
.r6-settings .r6-settings-layout > nav > button:active:not(:disabled) {
|
||||
color: #304757;
|
||||
background: #d4e2ea;
|
||||
}
|
||||
.r6-settings .r6-settings-content {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 710px;
|
||||
padding: 25px 31px;
|
||||
min-height: 560px;
|
||||
padding: 28px 34px;
|
||||
background: #fff;
|
||||
border: 1px solid #dce4eb;
|
||||
border-radius: 16px;
|
||||
border: 0;
|
||||
border-radius: 0 11px 11px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.r6-settings .r6-settings-content > section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.r6-settings .r6-settings-content > section > h2 {
|
||||
min-height: 36px;
|
||||
min-height: 32px;
|
||||
font:
|
||||
500 22px / 36px "Noto Sans SC",
|
||||
600 20px / 32px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
margin: 0;
|
||||
text-wrap: balance;
|
||||
}
|
||||
.r6-settings .r6-setting-row {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 30px 0 25px;
|
||||
gap: 32px;
|
||||
padding: 24px 0;
|
||||
border-bottom: 1px solid #d8e2ea;
|
||||
min-height: 132px;
|
||||
min-height: 108px;
|
||||
}
|
||||
.r6-settings .r6-setting-row > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 68ch;
|
||||
}
|
||||
.r6-settings .r6-setting-row > button {
|
||||
margin-top: 12px;
|
||||
margin-top: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.r6-settings .r6-setting-row h3 {
|
||||
font:
|
||||
400 18px / 28px "Noto Sans SC",
|
||||
500 16px / 26px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
margin: 0 0 10px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.r6-settings .r6-setting-row p {
|
||||
font-size: 14px;
|
||||
line-height: 26px;
|
||||
color: #63707b;
|
||||
font-size: 13px;
|
||||
line-height: 23px;
|
||||
color: #5e6e7a;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.r6-settings .r6-setting-row > span {
|
||||
font-size: 13px;
|
||||
@@ -205,6 +274,97 @@
|
||||
font-size: 14px;
|
||||
color: #4f6373;
|
||||
}
|
||||
.r6-settings .connection-fallback {
|
||||
margin: 22px 0 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.r6-settings .connection-fallback summary {
|
||||
width: fit-content;
|
||||
min-height: 36px;
|
||||
padding: 6px 8px;
|
||||
margin-left: -8px;
|
||||
border-radius: 6px;
|
||||
color: #405665;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
transition:
|
||||
color 140ms ease-out,
|
||||
background-color 140ms ease-out;
|
||||
}
|
||||
.r6-settings .connection-fallback summary:hover {
|
||||
color: #304757;
|
||||
background: #e9f0f4;
|
||||
}
|
||||
.r6-settings .connection-fallback summary:active {
|
||||
color: #304757;
|
||||
background: #d4e2ea;
|
||||
}
|
||||
.r6-settings .connection-fallback .connection-boundary {
|
||||
max-width: 68ch;
|
||||
margin: 16px 0 20px;
|
||||
color: #5e6e7a;
|
||||
font-size: 13px;
|
||||
line-height: 23px;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
.r6-settings .connection-fallback .custom-harness-name {
|
||||
max-width: 560px;
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
color: #4f6373;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.r6-settings .connection-fallback .custom-harness-name input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 10px 13px;
|
||||
border: 1px solid #c6d2da;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #304757;
|
||||
font-size: 14px;
|
||||
}
|
||||
.r6-settings .connection-fallback .custom-harness-name input:hover {
|
||||
border-color: #aabcc8;
|
||||
}
|
||||
.r6-settings .connection-fallback .custom-harness-name input:focus-visible {
|
||||
outline: 2px solid #557c93;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.r6-settings .connection-fallback > button:not(.quiet) {
|
||||
min-height: 42px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid #5b6f7e;
|
||||
border-radius: 8px;
|
||||
background: #4f6373;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 140ms ease-out,
|
||||
transform 90ms ease-out;
|
||||
}
|
||||
.r6-settings .connection-fallback > button:not(.quiet):hover:not(:disabled) {
|
||||
border-color: #5b6f7e;
|
||||
background: #435966;
|
||||
color: #fff;
|
||||
}
|
||||
.r6-settings .connection-fallback > button:not(.quiet):active:not(:disabled) {
|
||||
background: #3d4f5d;
|
||||
color: #fff;
|
||||
transform: scale(0.985);
|
||||
}
|
||||
.r6-settings .connection-fallback > button:not(.quiet):disabled {
|
||||
border-color: #c6d2da;
|
||||
background: #dbe4e9;
|
||||
color: #647783;
|
||||
cursor: default;
|
||||
}
|
||||
.r6-settings .r6-settings-error {
|
||||
color: #953f3f;
|
||||
font-size: 14px;
|
||||
@@ -222,7 +382,7 @@
|
||||
line-height: 24px;
|
||||
}
|
||||
.r6-settings .r6-model-overview {
|
||||
padding: 32px 0 24px;
|
||||
padding: 26px 0 20px;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.r6-settings .r6-model-caption {
|
||||
@@ -233,7 +393,7 @@
|
||||
}
|
||||
.r6-settings .r6-current-model {
|
||||
font:
|
||||
400 24px / 40px "Noto Sans SC",
|
||||
500 22px / 34px "Noto Sans SC",
|
||||
"PingFang SC",
|
||||
sans-serif;
|
||||
margin: 0 0 7px;
|
||||
@@ -363,23 +523,26 @@
|
||||
}
|
||||
.r6-settings .r6-settings-help .r6-setting-row {
|
||||
display: flex;
|
||||
min-height: 132px;
|
||||
padding: 30px 0 25px;
|
||||
min-height: 108px;
|
||||
padding: 24px 0;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
.r6-settings .r6-settings-help .r6-setting-row h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.r6-settings .r6-settings-help .r6-setting-row p {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.r6-settings .r6-settings-help .r6-setting-row > button {
|
||||
.r6-settings .r6-settings-help .r6-setting-row > button:not(.r6-switch) {
|
||||
min-width: 132px;
|
||||
margin-top: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.r6-settings .r6-settings-help .r6-setting-row > .r6-switch {
|
||||
margin-left: auto;
|
||||
}
|
||||
.r6-settings .r6-settings-help .r6-settings-about {
|
||||
min-height: 0;
|
||||
padding-bottom: 24px;
|
||||
@@ -534,10 +697,9 @@
|
||||
}
|
||||
.r6-settings .r6-settings-layout {
|
||||
grid-template-columns: 176px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.r6-settings .r6-settings-content {
|
||||
padding: 25px 23px;
|
||||
padding: 26px 28px;
|
||||
}
|
||||
.r6-settings .r6-setting-row {
|
||||
flex-wrap: wrap;
|
||||
@@ -559,6 +721,9 @@
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #d5dfe8;
|
||||
border-radius: 11px 11px 0 0;
|
||||
}
|
||||
.r6-settings .r6-settings-layout > nav > button {
|
||||
font-size: 14px;
|
||||
@@ -566,6 +731,18 @@
|
||||
.r6-settings .model-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.r6-settings .r6-settings-content {
|
||||
min-height: 0;
|
||||
padding: 24px 20px;
|
||||
border-radius: 0 0 11px 11px;
|
||||
}
|
||||
.r6-settings .r6-language-setting-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
.r6-language-setting-description {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.r6-settings .r6-settings-layout > nav {
|
||||
|
||||
@@ -219,12 +219,14 @@
|
||||
}
|
||||
.r6-shell .r6-memory .map-inspector {
|
||||
padding: 14px 22px 24px;
|
||||
background: rgb(255 255 255 / 30%);
|
||||
background: #f2f6f8;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
isolation: isolate;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.r6-shell .r6-memory .map-inspector-heading {
|
||||
padding: 0;
|
||||
@@ -498,11 +500,8 @@
|
||||
width: min(418px, calc(100vw - 32px));
|
||||
}
|
||||
.r6-shell .r6-memory .map-inspector-heading {
|
||||
position: sticky;
|
||||
top: -14px;
|
||||
z-index: 1;
|
||||
background: #eff5f8;
|
||||
padding: 10px 0;
|
||||
position: static;
|
||||
padding: 0;
|
||||
}
|
||||
.selected-editor {
|
||||
padding-left: 0;
|
||||
|
||||
@@ -1809,8 +1809,7 @@ const baseEnglishMessages = {
|
||||
"{count} memories created{filter} · Showing {shown}",
|
||||
"memory:({filter})": " ({filter})",
|
||||
"memory:第 {page} 页": "Page {page}",
|
||||
"memory:原话已保存,正在等待整理:":
|
||||
"Original text saved, awaiting processing: ",
|
||||
"memory:正在等待整理:": "Awaiting processing: ",
|
||||
"memory:记忆未在生成": "Memories are not being created",
|
||||
"memory:还没有开始生成记忆:{reason}({count} 条待处理)":
|
||||
"Memories are not being created: {reason} ({count} pending)",
|
||||
|
||||
@@ -370,7 +370,7 @@ export const memoryChineseMessages = {
|
||||
"已沉淀 {count} 条{filter} · 当前展示 {shown} 条",
|
||||
"memory:({filter})": "({filter})",
|
||||
"memory:第 {page} 页": "第 {page} 页",
|
||||
"memory:原话已保存,正在等待整理:": "原话已保存,正在等待整理:",
|
||||
"memory:正在等待整理:": "正在等待整理:",
|
||||
"memory:记忆未在生成": "记忆未在生成",
|
||||
"memory:还没有开始生成记忆:{reason}({count} 条待处理)":
|
||||
"还没有开始生成记忆:{reason}({count} 条待处理)",
|
||||
@@ -786,8 +786,7 @@ export const memorySpanishMessages: Record<
|
||||
"{count} memoria guardada{filter} · {shown} visibles",
|
||||
"memory:({filter})": " ({filter})",
|
||||
"memory:第 {page} 页": "Página {page}",
|
||||
"memory:原话已保存,正在等待整理:":
|
||||
"Texto guardado; pendiente de procesar: ",
|
||||
"memory:正在等待整理:": "Pendiente de procesar: ",
|
||||
"memory:记忆未在生成": "Generación de memorias detenida",
|
||||
"memory:还没有开始生成记忆:{reason}({count} 条待处理)":
|
||||
"Aún no se generan memorias: {reason} ({count} pendientes)",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hy-memory"
|
||||
version = "2.0.6"
|
||||
version = "2.0.7"
|
||||
description = "HY Memory - Industrial-grade dual-system cognitive memory framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""经验与事实记下对话时间 memory_at,并以秒级 epoch 对外返回(与 chat 一致)。
|
||||
|
||||
之前生产力链路丢了 add() 传进来的 memory_at,coding_memory_meta / working_fact
|
||||
也没有这一列,MemHub 里所有经验都显示"时间未知"。
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hy_memory.client import HyMemoryClient
|
||||
from hy_memory.coding.extractor import CodingMemoryExtractor
|
||||
from hy_memory.coding.reconciler import CodingMemoryReconciler
|
||||
from hy_memory.coding.store import CodingMemoryStore, _CREATE_TABLE_SQL, _CREATE_FACT_TABLE_SQL
|
||||
from hy_memory.coding.types import CodingMemory, WorkingFact
|
||||
from hy_memory.coding.writer import CodingWriter
|
||||
|
||||
from tests.coding._helpers import MockLLMProvider
|
||||
from tests.coding.test_list_and_stats_by_scene import _client, _unwrapped
|
||||
from tests.coding.test_write_scene_routing import _messages
|
||||
from tests.coding.test_writer_e2e import _coding_messages, _extract_resp, _reconcile_resp
|
||||
|
||||
T1 = datetime(2026, 10, 1, 9, 30, tzinfo=timezone.utc)
|
||||
T2 = T1 + timedelta(days=3)
|
||||
|
||||
|
||||
def _epoch(value: datetime) -> int:
|
||||
return int(value.timestamp())
|
||||
|
||||
|
||||
def _writer(store, embed, llm):
|
||||
return CodingWriter(
|
||||
store=store,
|
||||
extractor=CodingMemoryExtractor(llm),
|
||||
reconciler=CodingMemoryReconciler(store, embed, llm),
|
||||
llm_provider=llm, embed_service=embed,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestStore:
|
||||
async def test_round_trip_as_epoch(self, coding_store):
|
||||
await coding_store.insert(CodingMemory(
|
||||
experience_id="e1", user_id="u", task="T", solution="S", memory_at=T1,
|
||||
))
|
||||
await coding_store.insert_fact(WorkingFact(
|
||||
fact_id="f1", user_id="u", statement="x", memory_at=T1,
|
||||
))
|
||||
exp = await coding_store.get_by_id("e1", user_id="u")
|
||||
fact = await coding_store.get_fact("f1", user_id="u")
|
||||
assert _epoch(exp.memory_at) == _epoch(T1)
|
||||
assert exp.to_dict()["memory_at"] == _epoch(T1)
|
||||
assert fact.to_dict()["memory_at"] == _epoch(T1)
|
||||
|
||||
async def test_missing_time_stays_unknown(self, coding_store):
|
||||
await coding_store.insert(CodingMemory(
|
||||
experience_id="e1", user_id="u", task="T", solution="S",
|
||||
))
|
||||
exp = await coding_store.get_by_id("e1", user_id="u")
|
||||
assert exp.memory_at is None and exp.to_dict()["memory_at"] is None
|
||||
|
||||
@pytest.mark.parametrize("old, new", [(T1, T2), (T2, T1), (T2, None)])
|
||||
async def test_update_keeps_the_later_time(self, coding_store, old, new):
|
||||
await coding_store.insert(CodingMemory(
|
||||
experience_id="e1", user_id="u", task="T", solution="S",
|
||||
session_id="s", memory_at=old,
|
||||
))
|
||||
await coding_store.update(CodingMemory(
|
||||
experience_id="e1", user_id="u", task="T", solution="S2",
|
||||
session_id="s", memory_at=new,
|
||||
))
|
||||
exp = await coding_store.get_by_id("e1", user_id="u")
|
||||
assert exp.solution == "S2"
|
||||
assert _epoch(exp.memory_at) == _epoch(T2)
|
||||
|
||||
async def test_edit_keeps_the_time(self, coding_store):
|
||||
await coding_store.insert(CodingMemory(
|
||||
experience_id="e1", user_id="u", task="T", solution="S", memory_at=T1,
|
||||
))
|
||||
await coding_store.edit_experience("e1", user_id="u", solution="S2")
|
||||
exp = await coding_store.get_by_id("e1", user_id="u")
|
||||
assert _epoch(exp.memory_at) == _epoch(T1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("variant", ["working", "coding"])
|
||||
async def test_existing_database_gains_the_column_on_open(coding_config, mock_embed, variant):
|
||||
store = CodingMemoryStore(coding_config, embed_service=mock_embed, variant=variant)
|
||||
# 先按老 schema(没有 memory_at)建库并放一行
|
||||
conn = sqlite3.connect(store._db_path)
|
||||
conn.executescript(_CREATE_TABLE_SQL.replace(" memory_at INTEGER,\n", ""))
|
||||
conn.executescript(_CREATE_FACT_TABLE_SQL.replace(" memory_at INTEGER,\n", ""))
|
||||
conn.execute(
|
||||
"INSERT INTO coding_memory_meta (experience_id, user_id, task, solution, "
|
||||
"created_at, updated_at) VALUES ('old', 'u', 'T', 'S', '2026-01-01T00:00:00', "
|
||||
"'2026-01-01T00:00:00')"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
await store.initialize()
|
||||
try:
|
||||
old = await store.get_by_id("old", user_id="u")
|
||||
assert old is not None and old.memory_at is None
|
||||
await store.insert_fact(WorkingFact(
|
||||
fact_id="f1", user_id="u", statement="x", memory_at=T1,
|
||||
))
|
||||
assert _epoch((await store.get_fact("f1")).memory_at) == _epoch(T1)
|
||||
finally:
|
||||
await store.close()
|
||||
# 再开一次不出错(ADD COLUMN 是幂等的)
|
||||
again = CodingMemoryStore(coding_config, embed_service=mock_embed, variant=variant)
|
||||
await again.initialize()
|
||||
await again.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestWriter:
|
||||
def _llm(self):
|
||||
return MockLLMProvider([
|
||||
_extract_resp(
|
||||
[{"task": "打包发布", "solution": "S", "confidence": 0.9}],
|
||||
[{"statement": "发布脚本在 scripts/publish_package.sh"}],
|
||||
),
|
||||
_reconcile_resp([{"draft_idx": 0, "action": "ADD"}]),
|
||||
])
|
||||
|
||||
async def test_conversation_time_reaches_both_layers(self, coding_store, mock_embed):
|
||||
resp = await _writer(coding_store, mock_embed, self._llm()).write(
|
||||
_coding_messages(), user_id="u", session_id="s", memory_at=T1,
|
||||
)
|
||||
exp = await coding_store.get_by_id(resp["experience_ids"][0], user_id="u")
|
||||
fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u")
|
||||
assert _epoch(exp.memory_at) == _epoch(T1)
|
||||
assert _epoch(fact.memory_at) == _epoch(T1)
|
||||
|
||||
async def test_defaults_to_request_time_like_chat(self, coding_store, mock_embed):
|
||||
before = int(time.time())
|
||||
resp = await _writer(coding_store, mock_embed, self._llm()).write(
|
||||
_coding_messages(), user_id="u", session_id="s",
|
||||
)
|
||||
exp = await coding_store.get_by_id(resp["experience_ids"][0], user_id="u")
|
||||
fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u")
|
||||
for stamp in (_epoch(exp.memory_at), _epoch(fact.memory_at)):
|
||||
assert before <= stamp <= int(time.time()) + 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_passes_the_time_to_the_productivity_writer(monkeypatch):
|
||||
seen = {}
|
||||
|
||||
async def _judge(*args, **kwargs):
|
||||
return "working"
|
||||
|
||||
class _Writer:
|
||||
async def write(self, *args, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return {"success": True, "experience_ids": [], "fact_ids": []}
|
||||
|
||||
async def _get_writer(*, variant):
|
||||
return _Writer()
|
||||
|
||||
monkeypatch.setattr("hy_memory.client.classify_messages_scene", _judge)
|
||||
ns = SimpleNamespace(
|
||||
_config=SimpleNamespace(coding=SimpleNamespace(enable=True)),
|
||||
_coding_llm_provider=object(),
|
||||
_cache=None,
|
||||
_get_coding_writer=_get_writer,
|
||||
_normalize_scene=HyMemoryClient._normalize_scene,
|
||||
_scene_variant=HyMemoryClient._scene_variant,
|
||||
_parse_input=HyMemoryClient._parse_input,
|
||||
)
|
||||
await HyMemoryClient._async_add_impl(
|
||||
ns, _messages(), user_id="u1", agent_id="a1", memory_at=T1,
|
||||
)
|
||||
assert seen["memory_at"] == T1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_get_and_search_return_epoch_seconds(coding_config, mock_embed):
|
||||
coding = CodingMemoryStore(coding_config, embed_service=mock_embed, variant="coding")
|
||||
await coding.initialize()
|
||||
try:
|
||||
await coding.insert(CodingMemory(
|
||||
experience_id="e1", user_id="u", task="打包发布", solution="S", memory_at=T1,
|
||||
))
|
||||
await coding.insert_fact(WorkingFact(
|
||||
fact_id="f1", user_id="u", statement="打包发布", memory_at=T2,
|
||||
derived_experience_ids=["e1"],
|
||||
))
|
||||
client = _client([], {"coding": coding})
|
||||
list_memories = _unwrapped("async_list_memories")
|
||||
|
||||
page = await list_memories(client, user_id="u", scene="coding")
|
||||
assert page["experiences"][0]["memory_at"] == _epoch(T1)
|
||||
|
||||
merged = await list_memories(client, user_id="u", scene="all")
|
||||
assert merged["memories"][0]["memory_at"] == _epoch(T1)
|
||||
|
||||
exp = await coding.get_by_id("e1", user_id="u")
|
||||
got = HyMemoryClient._format_productivity_get(
|
||||
exp, kind="experience", variant="coding", request_id="r", elapsed_ms=0,
|
||||
)
|
||||
assert got["memory_at"] == _epoch(T1)
|
||||
|
||||
client._embed_service = mock_embed
|
||||
client._coding_store = coding
|
||||
|
||||
async def _ready(variant="working"):
|
||||
return None
|
||||
|
||||
client._get_coding_writer = _ready
|
||||
client._cache = None
|
||||
resp = await HyMemoryClient._search_coding(
|
||||
client, target_query="打包发布", user_ids=["u"], workspace_id=None,
|
||||
branch=None, limit=5, working_fact_min_score=0.0,
|
||||
working_experience_min_score=0.0, request_id="r",
|
||||
t0=time.perf_counter(), variant="coding",
|
||||
)
|
||||
assert resp["facts"][0]["memory_at"] == _epoch(T2)
|
||||
assert resp["experiences"][0]["memory_at"] == _epoch(T1)
|
||||
unified = HyMemoryClient._search_lane_items("coding", resp)
|
||||
assert {item["memory_at"] for item in unified} == {_epoch(T1), _epoch(T2)}
|
||||
finally:
|
||||
await coding.close()
|
||||
Reference in new issue
Block a user