1 line
117 KiB
JSON
1 line
117 KiB
JSON
{"repo": "afx-team/hebb-mind", "files": {"repo_pages/advanced/multi-model.md": {"36ce983c5644adfe06c176912b58b3284753db7b": {"t": 1785674177.2575505, "n": 4564, "h": "81172f0d70629b36", "c": "---\ndescription: \"Configure any LLM provider for AI agent memory consolidation via LiteLLM: OpenAI, Anthropic Claude, Qwen, GLM, Kimi. Local sentence-transformers embeddings, no API cost.\"\n---\n\n# Multi-Model Support\n\nHebb Mind supports multiple LLM providers through [LiteLLM](https://github.com/BerriAI/litellm). This enables memory consolidation with any major language model.\n\n## Supported Providers\n\n| Provider | Model Example | Configuration |\n|----------|--------------|---------------|\n| OpenAI | `openai/gpt-4o-mini` | `llm_api_key` |\n| OpenAI | `openai/gpt-4o` | `llm_api_key` |\n| Anthropic | `anthropic/claude-3-haiku-20240307` | `llm_api_key` |\n| Anthropic | `anthropic/claude-3-5-sonnet-20241022` | `llm_api_key` |\n| Qwen (Alibaba) | `openai/qwen-plus` | `llm_api_key` + `llm_base_url` |\n| GLM (Zhipu) | `openai/glm-4` | `llm_api_key` + `llm_base_url` |\n| Kimi (Moonshot) | `openai/moonshot-v1-8k` | `llm_api_key` + `llm_base_url` |\n\n## Configuration\n\n### OpenAI\n\n```bash\nhebb config set llm_model openai/gpt-4o-mini\nhebb config set llm_api_key sk-your-openai-key\n```\n\n### Anthropic\n\n```bash\nhebb config set llm_model anthropic/claude-3-haiku-20240307\nhebb config set llm_api_key sk-ant-your-anthropic-key\n```\n\n### Qwen (Alibaba Cloud)\n\n```bash\nhebb config set llm_model openai/qwen-plus\nhebb config set llm_api_key sk-your-qwen-key\nhebb config set llm_base_url https://dashscope.aliyuncs.com/compatible-mode/v1\n```\n\n### GLM (Zhipu AI)\n\n```bash\nhebb config set llm_model openai/glm-4\nhebb config set llm_api_key your-zhipu-key\nhebb config set llm_base_url https://open.bigmodel.cn/api/paas/v4\n```\n\n### Kimi (Moonshot AI)\n\n```bash\nhebb config set llm_model openai/moonshot-v1-8k\nhebb config set llm_api_key sk-your-moonshot-key\nhebb config set llm_base_url https://api.moonshot.cn/v1\n```\n\n## How It Works\n\nFor Chinese model providers (Qwen, GLM, Kimi), the `openai/` prefix tells LiteLLM to use the OpenAI-compatible API format. The `llm_base_url` points to the provider's endpoint. This works because these providers implement the OpenAI chat completion API specification.\n\n## Embedding Model\n\nThe embedding model runs **locally** via sentence-transformers. No external API calls are needed for generating embeddings. By default, `hebb setup` selects a **small** model by content language:\n\n- English: `all-MiniLM-L6-v2` (~90 MB)\n- Chinese or multilingual: `intfloat/multilingual-e5-small` (~470 MB)\n\nFor the high-quality tier (1–2 GB), opt in with `hebb setup --profile best`:\n\n- English: `BAAI/bge-large-en-v1.5`\n- Chinese or multilingual: `BAAI/bge-m3`\n\nDownload region is independent from language. Use `hebb setup --language en --region cn` for English content on a China network, or `hebb setup --language zh --region global` for Chinese content on a global network.\n\nTo swap to a different model after first install:\n\n```bash\nhebb config set embedding_model \"paraphrase-multilingual-MiniLM-L12-v2\"\nhebb config set embedding_dim 384\nhebb service restart\nhebb memory reembed # required if the dimension changed\n```\n\n::: tip\nChanging the embedding dimension invalidates all stored vectors — the vector table is auto-reset on next startup. Run `hebb memory reembed` afterwards to repopulate. See [Switch the Embedding Model](../guide/switch-embedding-model.md) for the full walkthrough (CLI, Web Console, and re-embed details).\n:::\n\nThis means:\n\n- Embedding is free -- no API costs\n- Low latency -- no network round-trip\n- Privacy -- your text never leaves the machine for embedding\n- Offline capable -- works without internet after initial download\n\nThe embedding model is separate from the LLM model. You can use any LLM provider while keeping the local embedding model.\n\n## Testing Your Configuration\n\nAfter configuring a model, test the connection:\n\n```bash\ncurl -X POST http://localhost:8321/api/v1/admin/config/test-llm \\\n -H 'Content-Type: application/json' \\\n -d '{\"model\": \"openai/gpt-4o-mini\", \"api_key\": \"sk-...\"}'\n```\n\nThis sends a simple test request to verify that the model, API key, and endpoint are working correctly. Add `\"base_url\": \"https://...\"` for OpenAI-compatible providers (Qwen, GLM, Kimi).\n\n## Choosing a Model\n\nFor memory consolidation, smaller and faster models work well since the task involves classification and summarization rather than creative generation. Recommended starting points:\n\n- **Budget-conscious**: `openai/gpt-4o-mini` or `openai/qwen-plus`\n- **Higher quality**: `openai/gpt-4o` or `anthropic/claude-3-5-sonnet-20241022`\n- **Chinese-language memories**: `openai/qwen-plus` or `openai/glm-4`\n"}}, "README.md": {"36ce983c5644adfe06c176912b58b3284753db7b": {"t": 1785674233.8787296, "n": 15309, "h": "04bc282f80e95fb5", "c": "<p align=\"center\">\n <h1 align=\"center\"><a href=\"https://afx-team.github.io/hebb-mind/\"><img src=\"logo.svg\" width=\"40\" height=\"40\" alt=\"Hebb Mind logo\" valign=\"middle\"/> Hebb Mind</a></h1>\n <p align=\"center\"><strong>A neuroscience-inspired memory framework for AI agents</strong></p>\n <p align=\"center\"><em>Encode. Consolidate. Activate. Forget.</em></p>\n <p align=\"center\"><a href=\"https://afx-team.github.io/hebb-mind/\">Documentation</a> · <a href=\"README.md\">English</a> | <a href=\"README_ZH.md\">中文</a></p>\n</p>\n\n<p align=\"center\">\n <a href=\"https://afx-team.github.io/hebb-mind/\"><img src=\"https://img.shields.io/badge/docs-afx--team.github.io-blue\" alt=\"Documentation\"></a>\n <a href=\"https://github.com/afx-team/hebb-mind/actions\"><img src=\"https://github.com/afx-team/hebb-mind/actions/workflows/ci.yml/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://pypi.org/project/hebb-mind/\"><img src=\"https://img.shields.io/pypi/v/hebb-mind\" alt=\"PyPI\"></a>\n <a href=\"https://github.com/afx-team/hebb-mind/blob/main/LICENSE\"><img src=\"https://img.shields.io/badge/license-MIT-blue\" alt=\"License\"></a>\n <img src=\"https://img.shields.io/pypi/pyversions/hebb-mind\" alt=\"Python\">\n</p>\n\n---\n\nHebb Mind gives AI agents a neuroscience-inspired memory loop — **encode → replay → consolidate → forget**. A `pipx install` and one command stand up a local REST + MCP endpoint: SQLite for storage, sentence-transformers for embedding, NetworkX for the tag graph. **Zero external services** — bring an LLM key only when you want consolidation to do its work.\n\n\n<p align=\"center\">\n <img src=\"repo_pages/public/architecture-en.jpg\" alt=\"Hebb Mind architecture — agent integrations · hebb CLI · 4-stage memory loop with LLM-driven 7-step consolidation agent · SQLite + pgvector\" width=\"900\">\n</p>\n\n## Quick Start\n\n### Try in ~60 seconds — no API key needed\n\nIngest and hybrid search work fully offline with a local embedding model.\n\n```bash\npipx install hebb-mind\nhebb setup # downloads a small embedding model based on your OS locale\nhebb service install # registers a background service (launchd / systemd / Task Scheduler)\n```\n\n`hebb setup` downloads a small embedding model only if it isn't already cached —\n~90MB for English (`all-MiniLM-L6-v2`), ~470MB for multilingual\n(`intfloat/multilingual-e5-small`). The ~60-second figure is for the English /\n`--profile fast` small-model path; the multilingual model is a larger download.\nWant the high-quality models? `hebb setup --profile best` pulls the BAAI `bge`\nfamily instead (1–2GB+) — opt-in, never downloaded by default.\n\n**Don't have `pipx`?** It's the recommended installer for Python CLI tools — isolated venv, automatic PATH, plays nice with PEP 668. Install it once:\n\n```bash\n# macOS (Homebrew)\nbrew install pipx && pipx ensurepath\n\n# Linux — Debian / Ubuntu 23.04+\nsudo apt install pipx && pipx ensurepath\n\n# Linux — Fedora\nsudo dnf install pipx && pipx ensurepath\n\n# Windows / any platform with Python 3.10+\npython -m pip install --user pipx && python -m pipx ensurepath\n```\n\nThen **open a new terminal** so the updated `PATH` takes effect, and re-run `pipx install hebb-mind`.\n\nPrefer plain `pip` instead? `python -m venv .venv && source .venv/bin/activate && pip install -U hebb-mind` works fine — `hebb` lives on the venv's `PATH` automatically.\n\nHebb Mind runs as an OS-managed background service — no foreground process to keep alive, no `start`/`stop` shells to remember. The service is per-user by default and needs no admin/sudo. Use `--scope system` for a system-wide install. See `hebb service --help`.\n\nIn another shell:\n\n```bash\ncurl -X POST http://localhost:8321/api/v1/memories \\\n -H 'Content-Type: application/json' \\\n -d '{\"content\": \"User prefers dark mode and compact layout\", \"tags\": [\"preference\", \"ui\"]}'\n\ncurl -X POST http://localhost:8321/api/v1/search \\\n -H 'Content-Type: application/json' \\\n -d '{\"query\": \"UI preferences\", \"top_k\": 5}'\n```\n\nOpen <http://localhost:8321/> for the Web Console.\n\n<p align=\"center\">\n <img src=\"repo_pages/public/web-console-hero.jpg\" alt=\"Hebb Mind Web Console — partitioned memories and tag graph\" width=\"760\">\n</p>\n\nUse **Agent Sync** in the Web Console to make Hebb Mind the shared memory hub for Claude Code and Codex. It collects local session history from both tools, shows synced vs pending turns, and imports pending turns into the Hebb Mind database. The same workflow is available from the CLI:\n\n```bash\nhebb agent-sync list --host codex\nhebb agent-sync sync --host codex --dry-run\n```\n\n### Full experience (5 min) — enable LLM consolidation\n\nConsolidation, conflict resolution, and tag extraction need an LLM backend. The gate is `llm_model` — until it's set, those endpoints are a no-op (see [#consolidation-no-op](https://afx-team.github.io/hebb-mind/troubleshooting.html)). A hosted provider also needs `llm_api_key`; a local model (e.g. Ollama via `llm_base_url`) does not.\n\n```bash\nhebb config set llm_model openai/gpt-4o-mini # required — enables consolidation\nhebb config set llm_api_key sk-... # for hosted providers\n# For Qwen / GLM / Kimi via LiteLLM:\nhebb config set llm_base_url https://dashscope.aliyuncs.com/compatible-mode/v1\n```\n\nTrigger consolidation manually, or wait for the daily 18:00 job:\n\n```bash\ncurl -X POST http://localhost:8321/api/v1/admin/consolidate\n```\n\n## Installation Paths\n\n```bash\npipx install hebb-mind # recommended (isolated CLI install)\npipx install 'hebb-mind[pg]' # + PostgreSQL/pgvector\npipx upgrade hebb-mind # upgrade later\nhebb claude-code install --scope user # Claude Code: hooks-based recall + turn capture\nhebb codex install # Codex: project MCP + automatic memory hooks\nhebb agent-sync list # Audit Claude Code / Codex session sync status\nhebb agent-sync sync --dry-run # Preview historical session import\n```\n\nDocker, one-line install, and source build: [Installation Guide](https://afx-team.github.io/hebb-mind/guide/installation.html).\n\n## 30-second Python SDK\n\n```python\nfrom hebb import HebbMind\n\nmem = HebbMind() # resolves hebb.json from cwd → $HEBB_HOME → ~/.hebb\n\nmem.add(\"User prefers dark mode\", tags=[\"preference\", \"ui\"], importance=7.5)\nmem.add(\"User uses VS Code with the One Dark theme\", tags=[\"preference\", \"tools\"])\n\nfor hit in mem.search(\"UI preferences\", top_k=5):\n print(hit.score, hit.memory.content)\n```\n\nThe `HebbMind()` facade runs the memory engine in-process (storage + embedder +\ngraph + hybrid search) — no HTTP server, no daemon required. It uses the same\ncomponents the REST server builds, minus the network layer.\n\n## The memory loop\n\nThe same four stages, every day, in roughly the same order the brain runs them:\n\n| Stage | Brain analogue | What happens here | Trigger |\n|-------|----------------|-------------------|---------|\n| **Encoding** | Hippocampal CA1 captures the moment | New memories land in the working-memory inbox (`mem_hippocampus`) | API write |\n| **Replay & consolidation** | Sharp-wave ripples during slow-wave sleep | Agent classifies into a partition, resolves conflicts, writes tags into the knowledge graph | Daily 18:00 / manual |\n| **Retrieval** | Pattern completion in CA3 | Three-path hybrid search (vector + keyword + graph), scored on recency / importance / relevance | API search |\n| **Forgetting** | Synaptic pruning + the Ebbinghaus curve | Dynamic TTL on access count and importance — neglected memories fade | Periodic |\n\nWalkthroughs: [Memory Lifecycle](https://afx-team.github.io/hebb-mind/concepts/memory-lifecycle.html) · [Hybrid Search](https://afx-team.github.io/hebb-mind/concepts/hybrid-search.html) · [Architecture diagram](https://afx-team.github.io/hebb-mind/#architecture)\n\n## Comparison\n\nHonest summary; full table on the [docs site](https://afx-team.github.io/hebb-mind/#why-hebb-mind).\n\n| Feature | Mem0 | Letta | Zep | **Hebb Mind** |\n|---|---|---|---|---|\n| Self-hosted Web UI | Cloud only ([discussion](https://github.com/mem0ai/mem0/discussions/3599)) | Cloud only | Cloud only | **Built-in SPA** |\n| Knowledge graph | Pluggable ([removed in v3](https://docs.mem0.ai/migration/oss-v2-to-v3)) | No | Yes (Graphiti) | Tag-based (NetworkX) |\n| Memory consolidation | Append-only | Sleeptime Agent | Contradiction resolve | **Auto + conflict resolve** |\n| Forgetting / decay | No | No | Temporal invalidation | **Dynamic TTL** |\n| Zero-config local deploy | Needs API key | Needs API key + DB | Needs Postgres + Neo4j | **SQLite + local embed** |\n\n## Configuration\n\nAll config lives in `hebb.json`. Common settings:\n\n```bash\nhebb config list\nhebb config set llm_model openai/gpt-4o-mini\nhebb config set storage_type postgresql\nhebb config set pg_url postgresql://user:pass@localhost/hebb\n```\n\nFull reference: [Configuration Guide](https://afx-team.github.io/hebb-mind/guide/configuration.html).\n\n## API\n\nREST docs at `http://localhost:8321/docs` once the server is running. Key endpoints:\n\n| Method | Endpoint | Purpose |\n|--------|----------|---------|\n| `POST` | `/api/v1/memories` | Store a memory |\n| `POST` | `/api/v1/search` | Hybrid search |\n| `POST` | `/api/v1/admin/consolidate` | Run consolidation now (requires `llm_model`) |\n| `GET` | `/api/v1/graph/tags` | List knowledge-graph tags |\n| `GET` | `/api/v1/graph/neighbors/{tag}?depth=2` | Walk the tag graph |\n\n## Benchmarks\n\n**LoCoMo** (1,986 questions across 10 multi-session conversations; 1,978 scorable), session-level Recall@10 — the same metric MemPalace publishes.\n\n| System | Embedding | Rerank | R@10 |\n|---|---|---|---|\n| **Hebb Mind v0.1.6** | bge-large-1024 | bge-reranker-base | **95.75%** |\n| **Hebb Mind v0.1.6** | bge-large-1024 | — | **94.14%** |\n| MemPalace hybrid | bge-large-1024 | — | 92.40% |\n| **Hebb Mind v0.1.6** | MiniLM-384 | bge-reranker-base | **94.69%** |\n| MemPalace hybrid | MiniLM-384 | — | 92.63% |\n| **Hebb Mind v0.1.6** | MiniLM-384 | — | 91.41% |\n\nSame-embedding lead: +1.74 pp at bge-large with no rerank, +3.35 pp with rerank; the local cross-encoder even lifts the cheap MiniLM-384 to 94.69%, past MemPalace's tuned hybrid. End-to-end QA (same retrieval + DeepSeek-V4-Pro judge, full 1,978q): **77.1%**.\n\n**LongMemEval** (500 questions, LongMemEval-S) — session-level Recall@k (retrieval, apples-to-apples with MemPalace) and end-to-end QA (official reader + `get_anscheck_prompt` judge, comparable to Zep / Mem0).\n\n| System | Retrieval recall@10 | End-to-end QA | Reader LLM |\n|---|---|---|---|\n| **Hebb Mind v0.1.6** | **99.4%** | **79.0%** | DeepSeek-V4-Pro (neutral official prompt) |\n| Zep | 95.5% | 71.2% | gpt-4o |\n| Mem0 | not reported | ~85–94%¹ | gpt-4o (heavily-tuned prompt) |\n\nRetrieval R@5 = **99.0%**, tying MemPalace's best hybrid+rerank config (99.4%) and well above its raw 96.6% — on the same MiniLM-384 embedding. Hebb beats Zep at every retrieval depth (R@1 93.4% vs 75.9%) and leads its QA (79.0% vs 71.2%) with an *untuned* reader prompt; the gap to Mem0 is reader-prompt engineering, not memory — Hebb's retrieval is the stronger layer. <sup>¹ varies by source/setup.</sup>\n\n**MemBench** (ACL 2025; 11 categories, all topics, 11,996 questions) — turn-level Hit@5 against the dataset's `target_step_id` pointer, the metric MemPalace publishes. MCQ ground truth, so no LLM judge (random guessing alone scores 25%).\n\n| Category | Hebb Mind v0.1.6 Hit@5 | MemPalace Hit@5 | Δ |\n|---|---|---|---|\n| noisy | **79.4%** | 43.4% | +36.0 pp |\n| post_processing | **90.3%** | 56.6% | +33.7 pp |\n| conditional | **86.0%** | 57.3% | +28.7 pp |\n| highlevel_rec | **89.6%** | 76.2% | +13.4 pp |\n| **Overall (11 categories)** | **94.6%** | 80.3% | **+14.3 pp** |\n\nMiniLM-384 + bge-reranker-base. Hebb matches MemPalace on the easy categories (within ±4 pp) and wins decisively on all four hard ones — distractors, conditional reasoning, post-processing — exactly where verbatim-embedding retrieval collapses; the lever is the local cross-encoder rerank. Per-category k-curves on the docs site.\n\nHebb Mind's eval calls the same Claude Code hook code paths (`integrations/claude_code/{recall,stop}.py`) and `/api/v1/search` endpoint that the shipped product uses — the numbers above are what a user actually gets in production. Full methodology, per-category breakdowns, and prod-vs-eval-pipeline caveats: [hebb-mind.github.io/benchmarks](https://afx-team.github.io/hebb-mind/benchmarks/).\n\n## Why \"Hebb Mind\"?\n\nIn 1949, psychologist **Donald O. Hebb** proposed a rule that later got distilled into four words:\n\n> **Neurons that fire together, wire together.**\n\nA memory is not a *place* you look something up — it is a *pattern of connection*. Concepts that co-occur get physically linked into **cell assemblies**, and lighting up part of an assembly recalls the rest. Repetition strengthens the wiring; disuse lets it fade. That single rule — Hebbian learning — has shaped most of what came after in memory research and artificial neural networks.\n\n**Hebb Mind runs on that rule.** Its tag knowledge graph *is* a cell assembly: tags that appear together gain an edge, and every time they co-occur that edge grows stronger. Retrieval walks those edges, so a partial cue completes the whole pattern. Consolidation keeps what gets reinforced; forgetting prunes what does not — *fire together, wire together; neglect it, lose it.*\n\nThe **hippocampus** has a place here too — it names the working-memory partition (`mem_hippocampus`), the inbox where every new memory lands before consolidation. In the brain, the hippocampus is the gateway that holds new experience until it is wired into long-term cortical memory; in 1957, the patient known as H.M. lost both of his and could never form a new long-term memory again [(Squire, 1992; Tulving, 2002)](#acknowledgments). Today's AI agents are H.M. — every conversation starts from zero. Hebb Mind gives your agent that missing loop.\n\n## Contributing\n\nSetup: `pip install -e \".[dev]\" && pytest tests/ -v`. See [CONTRIBUTING.md](CONTRIBUTING.md).\n\n## Acknowledgments\n\n**Cognitive neuroscience.** Ebbinghaus, H. (1885). *Über das Gedächtnis*. · **Hebb, D. O. (1949). *The Organization of Behavior*. Wiley** — the namesake; the postulate behind \"fire together, wire together.\" · Tulving, E. (1972). Episodic and semantic memory. · Squire, L. R. (1992). Memory and the hippocampus: a synthesis from findings with rats, monkeys, and humans. *Psychological Review*, 99(2). · O'Reilly, R. C., & McClelland, J. L. (1994). Hippocampal conjunctive encoding, storage, and recall. *Hippocampus*, 4(6). · Wilson, M. A., & McNaughton, B. L. (1994). Reactivation of hippocampal ensemble memories during sleep. *Science*, 265(5172). · Tulving, E. (2002). Episodic memory: from mind to brain. *Annual Review of Psychology*, 53. · Buzsáki, G. (2015). Hippocampal sharp wave-ripple. *Hippocampus*, 25(10).\n\n**AI memory systems.** [Generative Agents](https://arxiv.org/abs/2304.03442) (scoring) · [MemGPT / Letta](https://arxiv.org/abs/2310.08560) (agent-driven memory) · [CoALA](https://arxiv.org/abs/2309.02427) (partition taxonomy) · [Graphiti](https://github.com/getzep/graphiti) (temporal KG). Survey notes in [`reports/papers/`](reports/papers/).\n\n> *\"Memory is the scribe of the soul.\" — Aristotle*\n> The brain solved this in deep time. We're just porting the loop.\n\n## License\n\n[MIT](LICENSE)\n"}}, "README_ZH.md": {"36ce983c5644adfe06c176912b58b3284753db7b": {"t": 1785674234.6684604, "n": 11008, "h": "36da7fedb627f63b", "c": "<p align=\"center\">\n <h1 align=\"center\"><a href=\"https://afx-team.github.io/hebb-mind/zh/\"><img src=\"logo.svg\" width=\"40\" height=\"40\" alt=\"Hebb Mind logo\" valign=\"middle\"/> Hebb Mind</a></h1>\n <p align=\"center\"><strong>一套受神经科学启发的 AI Agent 记忆框架 </strong></p>\n <p align=\"center\"><em>编码、巩固、激活、遗忘</em></p>\n <p align=\"center\"><a href=\"https://afx-team.github.io/hebb-mind/zh/\">文档</a> · <a href=\"README.md\">English</a> | <a href=\"README_ZH.md\">中文</a></p>\n</p>\n\n<p align=\"center\">\n <a href=\"https://afx-team.github.io/hebb-mind/zh/\"><img src=\"https://img.shields.io/badge/docs-afx--team.github.io-blue\" alt=\"Documentation\"></a>\n <a href=\"https://github.com/afx-team/hebb-mind/actions\"><img src=\"https://github.com/afx-team/hebb-mind/actions/workflows/ci.yml/badge.svg\" alt=\"CI\"></a>\n <a href=\"https://pypi.org/project/hebb-mind/\"><img src=\"https://img.shields.io/pypi/v/hebb-mind\" alt=\"PyPI\"></a>\n <a href=\"https://github.com/afx-team/hebb-mind/blob/main/LICENSE\"><img src=\"https://img.shields.io/badge/license-MIT-blue\" alt=\"License\"></a>\n <img src=\"https://img.shields.io/pypi/pyversions/hebb-mind\" alt=\"Python\">\n</p>\n\n---\n\nHebb Mind 给 AI Agent 装上一条受神经科学启发的记忆回路 —— **存储 → 激活 → 回放 → 巩固 → 遗忘**。`pipx install` 后一行命令即可在本地拉起 REST + MCP 端点:SQLite 做存储、sentence-transformers 做嵌入、NetworkX 维护标签图谱。**零外部服务**,只有想让巩固阶段\"工作\"时才需要 LLM Key。\n\n与同类相比,**Hebb Mind 是一个二进制、一条生物学意义上的回路。**\n\n<p align=\"center\">\n <img src=\"repo_pages/public/architecture-zh.jpg\" alt=\"Hebb Mind 架构 —— 接入层 · hebb CLI · 四阶段记忆回路与 LLM 驱动 7 步巩固 Agent · SQLite + pgvector\" width=\"900\">\n</p>\n\n## 快速开始\n\n### 约 60 秒上手 — 不需要 API Key\n\n写入和混合检索完全离线运行(基于本地 Embedding 模型)。\n\n```bash\npipx install hebb-mind\nhebb setup # 根据系统语言自动下载一个小型 Embedding 模型\nhebb service install # 注册操作系统后台服务(launchd / systemd / 任务计划程序)\n```\n\n`hebb setup` 仅在模型尚未缓存时下载一个小型 Embedding 模型 —— 英文约 90MB\n(`all-MiniLM-L6-v2`),多语言约 470MB(`intfloat/multilingual-e5-small`)。\n\"约 60 秒\"指英文 / `--profile fast` 小模型路径;多语言模型下载体积更大。\n想要高质量模型?用 `hebb setup --profile best` 拉取 BAAI `bge` 系列(1–2GB+)——\n这是可选的高质量档位,默认不下载。\n\n**还没装 `pipx`?** 它是 Python CLI 工具推荐的安装器:隔离 venv、自动配置 PATH、兼容 PEP 668。一次性装好就行:\n\n```bash\n# macOS(Homebrew)\nbrew install pipx && pipx ensurepath\n\n# Linux — Debian / Ubuntu 23.04+\nsudo apt install pipx && pipx ensurepath\n\n# Linux — Fedora\nsudo dnf install pipx && pipx ensurepath\n\n# Windows / 其他装了 Python 3.10+ 的环境\npython -m pip install --user pipx && python -m pipx ensurepath\n```\n\n然后**新开一个终端**让 `PATH` 生效,再回来跑 `pipx install hebb-mind`。\n\n更习惯用 `pip`?也可以:`python -m venv .venv && source .venv/bin/activate && pip install -U hebb-mind` —— `hebb` 自动落在 venv 的 `PATH` 上。\n\nHebb Mind 统一以操作系统后台服务的方式运行 —— 不再需要单独的前台进程,也不再有 `start`/`stop` 命令需要记忆。默认是用户级安装,**不需要管理员权限**;如果需要系统级常驻,可加 `--scope system`。详见 `hebb service --help`。\n\n另开一个终端:\n\n```bash\ncurl -X POST http://localhost:8321/api/v1/memories \\\n -H 'Content-Type: application/json' \\\n -d '{\"content\": \"用户偏好深色模式与紧凑布局\", \"tags\": [\"preference\", \"ui\"]}'\n\ncurl -X POST http://localhost:8321/api/v1/search \\\n -H 'Content-Type: application/json' \\\n -d '{\"query\": \"UI 偏好\", \"top_k\": 5}'\n```\n\n打开 <http://localhost:8321/> 即可使用 Web 控制台。\n\n<p align=\"center\">\n <img src=\"repo_pages/public/web-console-hero.jpg\" alt=\"Hebb Mind Web 控制台 — 分区记忆与标签图谱\" width=\"760\">\n</p>\n\n在 Web 控制台中使用 **Agent 同步**,可把 Hebb Mind 作为 Claude Code 与 Codex 的共享记忆中转站。它会收集两类工具的本机会话历史,显示已同步与待同步回合,并把待处理回合导入 Hebb Mind 数据库。同一流程也可通过 CLI 使用:\n\n```bash\nhebb agent-sync list --host codex\nhebb agent-sync sync --host codex --dry-run\n```\n\n### 完整体验(5 分钟)— 启用 LLM 巩固\n\n记忆巩固、冲突解决、标签提取需要一个 LLM 后端。开关由 `llm_model` 决定 —— 未设置前这些接口为 no-op(详见 [#consolidation-no-op](https://afx-team.github.io/hebb-mind/zh/troubleshooting.html))。托管 provider 还需要 `llm_api_key`;本地模型(例如通过 `llm_base_url` 接入的 Ollama)则不需要。\n\n```bash\nhebb config set llm_model openai/gpt-4o-mini # 必填 —— 启用巩固\nhebb config set llm_api_key sk-... # 托管 provider 需要\n# 通过 LiteLLM 接入通义千问 / GLM / Kimi:\nhebb config set llm_base_url https://dashscope.aliyuncs.com/compatible-mode/v1\n```\n\n手动触发巩固,或等待每日 18:00 的定时任务:\n\n```bash\ncurl -X POST http://localhost:8321/api/v1/admin/consolidate\n```\n\n## 安装方式\n\n```bash\npipx install hebb-mind # 推荐方式(隔离的 CLI 安装)\npipx install 'hebb-mind[pg]' # 启用 PostgreSQL/pgvector\npipx upgrade hebb-mind # 后续升级\nhebb claude-code install --scope user # Claude Code:基于 hooks 的召回 + 回合写入\nhebb codex install # Codex:项目级 MCP + 自动记忆 hooks\nhebb agent-sync list # 查看 Claude Code / Codex 会话同步状态\nhebb agent-sync sync --dry-run # 预览历史会话导入\n```\n\nDocker、一键脚本、源码安装详见 [安装指南](https://afx-team.github.io/hebb-mind/zh/guide/installation.html)。\n\n## 30 秒 Python SDK\n\n```python\nfrom hebb import HebbMind\n\nmem = HebbMind() # 按 cwd → $HEBB_HOME → ~/.hebb 顺序解析 hebb.json\n\nmem.add(\"用户偏好深色模式\", tags=[\"preference\", \"ui\"], importance=7.5)\nmem.add(\"用户使用 VS Code 与 One Dark 主题\", tags=[\"preference\", \"tools\"])\n\nfor hit in mem.search(\"UI 偏好\", top_k=5):\n print(hit.score, hit.memory.content)\n```\n\n`HebbMind()` 门面在进程内直接运行记忆引擎(存储 + 嵌入 + 图谱 + 混合检索)—— 不启动 HTTP 服务,也无需守护进程。它复用了 REST 服务在启动时构建的同一套组件,只是去掉了网络层。\n\n## 记忆回路\n\n每天,按照大脑大致相同的顺序运行同样四个阶段:\n\n| 阶段 | 大脑对应 | 在 Hebb Mind 里发生了什么 | 触发方式 |\n|------|---------|---------------------------|---------|\n| **编码** | 海马体 CA1 捕捉当下 | 新记忆进入工作记忆收件箱(`mem_hippocampus`) | API 写入 |\n| **回放与巩固** | 慢波睡眠中的尖波涟漪 | 巩固代理分类到分区、解决冲突、把标签写入知识图谱 | 每日 18:00 / 手动 |\n| **检索** | CA3 的模式补全 | 三路混合搜索(向量 + 关键词 + 图谱),按时效 / 重要性 / 相关性综合评分 | API 搜索 |\n| **遗忘** | 突触修剪 + 遗忘曲线 | 基于访问频率与重要性的动态 TTL — 被忽略的记忆自然消退 | 周期性 |\n\n详细说明:[记忆生命周期](https://afx-team.github.io/hebb-mind/zh/concepts/memory-lifecycle.html) · [混合检索](https://afx-team.github.io/hebb-mind/zh/concepts/hybrid-search.html) · [架构图](https://afx-team.github.io/hebb-mind/zh/#架构)\n\n## 横向对比\n\n简洁版本;完整对比表见 [文档站](https://afx-team.github.io/hebb-mind/zh/#为什么选-hebb-mind)。\n\n| 特性 | Mem0 | Letta | Zep | **Hebb Mind** |\n|---|---|---|---|---|\n| 自托管 Web UI | 仅云端([相关讨论](https://github.com/mem0ai/mem0/discussions/3599)) | 仅云端 | 仅云端 | **内置 SPA** |\n| 知识图谱 | 可插拔([v3 已移除](https://docs.mem0.ai/migration/oss-v2-to-v3)) | 无 | 有(Graphiti) | 标签图谱(NetworkX) |\n| 记忆巩固 | 仅追加 | Sleeptime Agent | 矛盾解决 | **自动 + 冲突解决** |\n| 遗忘 / 衰减 | 无 | 无 | 时序失效 | **动态 TTL** |\n| 零配置本地部署 | 需 API Key | 需 API Key + DB | 需 Postgres + Neo4j | **SQLite + 本地嵌入** |\n\n## 配置\n\n所有配置统一在 `hebb.json` 中管理。常用命令:\n\n```bash\nhebb config list\nhebb config set llm_model openai/gpt-4o-mini\nhebb config set storage_type postgresql\nhebb config set pg_url postgresql://user:pass@localhost/hebb\n```\n\n完整字段参考 [配置指南](https://afx-team.github.io/hebb-mind/zh/guide/configuration.html)。\n\n## API\n\n服务启动后访问 `http://localhost:8321/docs` 查看完整 REST 文档。主要端点:\n\n| 方法 | 端点 | 说明 |\n|------|------|------|\n| `POST` | `/api/v1/memories` | 写入记忆 |\n| `POST` | `/api/v1/search` | 混合搜索 |\n| `POST` | `/api/v1/admin/consolidate` | 立即触发巩固(需配置 `llm_model`) |\n| `GET` | `/api/v1/graph/tags` | 列出知识图谱标签 |\n| `GET` | `/api/v1/graph/neighbors/{tag}?depth=2` | 沿标签图谱游走 |\n\n## 基准测试\n\n**LoCoMo**(10 段多轮对话、共 1,986 题,1,978 道可评分),按 MemPalace 同口径的 session 级 Recall@10。\n\n| 系统 | Embedding | 重排 | R@10 |\n|---|---|---|---|\n| **Hebb Mind v0.1.6** | bge-large-1024 | bge-reranker-base | **95.75%** |\n| **Hebb Mind v0.1.6** | bge-large-1024 | — | **94.14%** |\n| MemPalace hybrid | bge-large-1024 | — | 92.40% |\n| **Hebb Mind v0.1.6** | MiniLM-384 | bge-reranker-base | **94.69%** |\n| MemPalace hybrid | MiniLM-384 | — | 92.63% |\n| **Hebb Mind v0.1.6** | MiniLM-384 | — | 91.41% |\n\n同 embedding 档位下:不开重排时 bge-large 领先 +1.74 pp,开重排后 +3.35 pp;本地 cross-encoder 甚至把廉价的 MiniLM-384 抬到 94.69%,超过 MemPalace 调优后的 hybrid。端到端 QA(同一检索 + DeepSeek-V4-Pro judge,完整 1,978 题):**77.1%**。\n\n**LongMemEval**(500 题,LongMemEval-S)—— session 级 Recall@k(检索,与 MemPalace 同口径)以及端到端 QA(官方 reader + `get_anscheck_prompt` 判分,与 Zep / Mem0 可比)。\n\n| 系统 | 检索 recall@10 | 端到端 QA | 作答 LLM |\n|---|---|---|---|\n| **Hebb Mind v0.1.6** | **99.4%** | **79.0%** | DeepSeek-V4-Pro(中立官方 prompt) |\n| Zep | 95.5% | 71.2% | gpt-4o |\n| Mem0 | 未公布 | ~85–94%¹ | gpt-4o(重度调优 prompt) |\n\n检索 R@5 = **99.0%**,在相同的 MiniLM-384 embedding 上追平 MemPalace 最佳「hybrid + 重排」配置(99.4%),远高于其 raw 96.6%。Hebb 在每个检索深度都胜过 Zep(R@1 93.4% vs 75.9%),并在 QA 上以**未调优**的 reader prompt 领先(79.0% vs 71.2%);与 Mem0 的差距来自 reader prompt 工程,而非记忆 —— Hebb 的检索才是更强的一层。<sup>¹ 随来源/设置而变。</sup>\n\n**MemBench**(ACL 2025;11 类、所有 topic、共 11,996 题)—— 轮次级 Hit@5,对照数据集的 `target_step_id` 指针,与 MemPalace 同口径。Ground truth 是单选题,因此不用 LLM 判分(瞎猜也有 25%)。\n\n| 类别 | Hebb Mind v0.1.6 Hit@5 | MemPalace Hit@5 | Δ |\n|---|---|---|---|\n| noisy | **79.4%** | 43.4% | +36.0 pp |\n| post_processing | **90.3%** | 56.6% | +33.7 pp |\n| conditional | **86.0%** | 57.3% | +28.7 pp |\n| highlevel_rec | **89.6%** | 76.2% | +13.4 pp |\n| **总体(11 类)** | **94.6%** | 80.3% | **+14.3 pp** |\n\nMiniLM-384 + bge-reranker-base。Hebb 在简单类别上与 MemPalace 持平(±4 pp 以内),在全部四个困难类别上大幅领先 —— 干扰项、条件推理、后处理 —— 这些恰恰是「逐字存储 + embedding」检索会崩溃的地方;关键杠杆是本地 cross-encoder 重排。逐类别 k 曲线见文档站。\n\nHebb Mind 的评测直接调用与生产同一份 Claude Code hook 代码路径(`integrations/claude_code/{recall,stop}.py`)与 `/api/v1/search`,因此上表数字就是用户在生产环境里实际能拿到的数字。完整方法学、分类拆解、benchmark vs production 流水线差异的说明:[hebb-mind.github.io/benchmarks](https://afx-team.github.io/hebb-mind/benchmarks/)。\n\n## 为什么叫 \"Hebb Mind\"?\n\n1949 年,心理学家 **唐纳德·赫布**(Donald O. Hebb)提出了一条法则,后来被浓缩成一句话:\n\n> **一起放电的神经元,会连到一起(neurons that fire together, wire together)。**\n\n记忆不是\"存放的地点\",而是\"连接的模式\"。共同出现的概念被物理地连成**细胞集群(cell assembly)**,激活其一部分就能唤回全部;重复会强化连接,弃用则任其消退。这条法则 — 赫布学习 — 很大程度影响了记忆系统研究、人工神经网络。\n\n**Hebb Mind 正是跑在这条法则上。** 它的标签知识图谱本身就是一个细胞集群:一起出现的标签之间会建立连边,每共现一次这条边就更强一分。检索沿着连边游走,于是一个局部线索就能补全整个模式。巩固保留被反复强化的部分,遗忘修剪没被强化的部分 — *一起放电就连到一起;无人问津便随之消逝。*\n\n**海马体(hippocampus)** 在这里也有一席之地 — 它是工作记忆分区(`mem_hippocampus`)的名字。在大脑中,海马体正是把新经验暂存、再逐步固化进长期皮层记忆的\"门户\";1957 年代号 H.M. 的患者被双侧切除海马体后,再也无法形成新的长期记忆 [(Squire, 1992; Tulving, 2002)](#致谢) — 今天的 AI Agent 就是 H.M.,每一次对话都从零开始。Hebb Mind 要为你的 Agent 补上这条缺失的回路。\n\n## 贡献\n\n环境搭建:`pip install -e \".[dev]\" && pytest tests/ -v`。详见 [CONTRIBUTING.md](CONTRIBUTING.md)。\n\n## 致谢\n\n**认知神经科学。** Ebbinghaus, H. (1885). *Über das Gedächtnis*. · **Hebb, D. O. (1949). *The Organization of Behavior*. Wiley** —项目的命名来源,\"fire together, wire together\" 背后的赫布假说。 · Tulving, E. (1972). Episodic and semantic memory. · Squire, L. R. (1992). Memory and the hippocampus: a synthesis from findings with rats, monkeys, and humans. *Psychological Review*, 99(2). · O'Reilly, R. C., & McClelland, J. L. (1994). Hippocampal conjunctive encoding, storage, and recall. *Hippocampus*, 4(6). · Wilson, M. A., & McNaughton, B. L. (1994). Reactivation of hippocampal ensemble memories during sleep. *Science*, 265(5172). · Tulving, E. (2002). Episodic memory: from mind to brain. *Annual Review of Psychology*, 53. · Buzsáki, G. (2015). Hippocampal sharp wave-ripple. *Hippocampus*, 25(10).\n\n**AI 记忆系统。** [Generative Agents](https://arxiv.org/abs/2304.03442)(评分模型)· [MemGPT / Letta](https://arxiv.org/abs/2310.08560)(Agent 驱动的记忆管理)· [CoALA](https://arxiv.org/abs/2309.02427)(分区分类法)· [Graphiti](https://github.com/getzep/graphiti)(时序知识图谱)。研究笔记见 [`reports/papers/`](reports/papers/)。\n\n> *\"记忆是灵魂的书记官。\" — 亚里士多德*\n> 大脑早已用亿万年解决了这个问题。我们只是在把那条回路移植过来。\n\n## 许可证\n\n[MIT License](LICENSE)\n"}}, "src/hebb/static/js/components/system.js": {"36ce983c5644adfe06c176912b58b3284753db7b": {"t": 1785746678.838663, "n": 39705, "h": "65267e286d170e1a", "c": "/**\n * System — infrastructure configuration (LLM · Embedding · Storage · Server).\n *\n * The memory-lifecycle config (recall pipeline, consolidation, forgetting) now\n * lives on the Activate / Consolidate / Forget pages; this page keeps only the\n * provider/storage/server plumbing. LLM and Embedding each have a guided setup\n * with a connection test; Storage and Server are plain key/value groups.\n */\n\nimport * as api from '../api.js';\nimport { t } from '../i18n.js';\nimport { success, error } from './toast.js';\nimport { buildGenericSection, offerRestart, esc } from './config-section.js';\n\n/* --- LLM provider presets for the guided UI --- */\nconst LLM_PRESETS = [\n { label: 'OpenAI', model: 'openai/gpt-4o-mini', url: '', placeholder_key: 'sk-...' },\n { label: 'Anthropic (Claude)', model: 'anthropic/claude-3-haiku-20240307', url: '', placeholder_key: 'sk-ant-...' },\n { label: 'Qwen (Alibaba)', model: 'openai/qwen-plus', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', placeholder_key: 'sk-...' },\n { label: 'GLM (Zhipu)', model: 'openai/glm-4-flash', url: 'https://open.bigmodel.cn/api/paas/v4', placeholder_key: '...' },\n { label: 'DeepSeek', model: 'openai/deepseek-chat', url: 'https://api.deepseek.com/v1', placeholder_key: 'sk-...' },\n { label: 'Kimi (Moonshot)', model: 'openai/moonshot-v1-8k', url: 'https://api.moonshot.cn/v1', placeholder_key: 'sk-...' },\n { label: 'Custom / Self-hosted', model: '', url: '', placeholder_key: '' },\n];\n\n/* --- Embedding provider presets, split by mode so Local and API are distinct --- */\nconst EMB_LOCAL_PRESETS = [\n { label: 'bge-large-en-v1.5 (English, 1024d)', model: 'BAAI/bge-large-en-v1.5', dim: 1024 },\n { label: 'bge-m3 (Multilingual, 1024d, 2.2GB)', model: 'BAAI/bge-m3', dim: 1024 },\n { label: 'all-MiniLM-L6-v2 (Fast, 384d, 87MB)', model: 'sentence-transformers/all-MiniLM-L6-v2', dim: 384 },\n { label: 'multilingual-e5-small (Multi, 384d, 470MB)', model: 'intfloat/multilingual-e5-small', dim: 384 },\n { label: 'Custom model…', model: '', dim: '' },\n];\nconst EMB_API_PRESETS = [\n { label: 'OpenAI — text-embedding-3-small', model: 'openai/text-embedding-3-small', url: 'https://api.openai.com/v1', dim: 1536 },\n { label: 'Cohere — embed-multilingual-v3.0', model: 'cohere/embed-multilingual-v3.0', url: 'https://api.cohere.com/v1', dim: 1024 },\n { label: 'Qwen (DashScope)', model: 'openai/text-embedding-v3', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', dim: 1024 },\n { label: 'Custom / Self-hosted', model: '', url: '', dim: '' },\n];\n\nconst EMB_HTTP_DEFAULTS = {\n method: 'POST',\n url: 'https://api.example.com/v1/embeddings',\n headers: '{\\n \"Authorization\": \"Bearer YOUR_KEY\",\\n \"Content-Type\": \"application/json\"\\n}',\n body: '{\\n \"model\": \"your-embedding-model\",\\n \"input\": {{input}}\\n}',\n response_path: 'data.*.embedding',\n};\n\nconst GROUP_STORAGE = {\n titleKey: 'settings.group.storage',\n icon: '🗃',\n keys: ['storage_type', 'pg_url', 'pg_pool_min', 'pg_pool_max'],\n};\nconst GROUP_WORKSPACE = {\n titleKey: 'settings.group.workspace',\n icon: '📁',\n keys: ['home'],\n};\nconst GROUP_SERVER = {\n titleKey: 'settings.group.server',\n icon: '🖥',\n keys: ['host', 'port'],\n};\n\nconst TABS = [\n { id: 'llm', labelKey: 'settings.tab.llm', build: (config) => [buildLLMSection(config)] },\n { id: 'embedding', labelKey: 'settings.tab.embedding', build: (config) => [buildEmbeddingSection(config)] },\n {\n id: 'storage',\n labelKey: 'settings.tab.storage',\n build: (config) => [buildGenericSection(GROUP_STORAGE, config), buildGenericSection(GROUP_WORKSPACE, config)],\n },\n { id: 'server', labelKey: 'settings.tab.server', build: (config) => [buildGenericSection(GROUP_SERVER, config)] },\n];\n\nexport async function renderSystem(root, sub) {\n root.innerHTML = `\n <div class=\"page-header\">\n <h1 class=\"page-title\">${t('system.title')}</h1>\n <p class=\"page-subtitle\">${t('system.subtitle')}</p>\n </div>\n <div class=\"console-tabs\" id=\"system-tabs\"></div>\n <div id=\"system-panel\">${t('common.loading')}</div>\n `;\n\n let config;\n try {\n config = await api.getConfig();\n } catch (e) {\n root.querySelector('#system-panel').innerHTML = `<div class=\"empty-state\">${e.message}</div>`;\n return;\n }\n\n const tabBar = root.querySelector('#system-tabs');\n const panel = root.querySelector('#system-panel');\n\n // Deep-link (#system/embedding) wins; else the remembered tab; else the first.\n let active = sub && TABS.some((x) => x.id === sub) ? sub : localStorage.getItem('hebb-system-tab');\n if (!TABS.some((tab) => tab.id === active)) active = TABS[0].id;\n\n tabBar.innerHTML = TABS.map(\n (tab) => `<button class=\"console-tab\" data-tab=\"${tab.id}\">${t(tab.labelKey)}</button>`\n ).join('');\n\n function showTab(id) {\n active = id;\n localStorage.setItem('hebb-system-tab', id);\n if (location.hash !== `#system/${id}`) history.replaceState(null, '', `#system/${id}`);\n tabBar.querySelectorAll('.console-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === id));\n panel.innerHTML = '';\n const tab = TABS.find((x) => x.id === id) || TABS[0];\n for (const section of tab.build(config)) panel.appendChild(section);\n }\n\n tabBar.querySelectorAll('.console-tab').forEach((btn) => {\n btn.addEventListener('click', () => showTab(btn.dataset.tab));\n });\n\n showTab(active);\n}\n\n/* ====================================================================\n LLM Section — guided setup with provider presets and test button\n ==================================================================== */\nfunction buildLLMSection(config) {\n const section = document.createElement('div');\n section.className = 'card mb-4';\n\n const isConfigured = Boolean(config.llm_model);\n\n section.innerHTML = `\n <div class=\"flex-between mb-4\">\n <h3 style=\"font-size:14px;font-weight:600;\">\n <span style=\"margin-right:6px\">🤖</span>${t('settings.llm_title')}\n ${isConfigured\n ? `<span class=\"tag tag-green\" style=\"font-size:10px;margin-left:8px\">${t('settings.configured')}</span>`\n : `<span class=\"tag tag-yellow\" style=\"font-size:10px;margin-left:8px\">${t('settings.not_configured')}</span>`}\n </h3>\n </div>\n ${!isConfigured ? `\n <div style=\"background:var(--bg-tertiary);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:13px;color:var(--text-secondary);line-height:1.6;\">\n ${t('settings.llm_guide')}\n </div>` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">${t('settings.provider')}</label>\n <select class=\"form-select\" id=\"llm-preset\" style=\"max-width:320px\">\n ${LLM_PRESETS.map((p, i) => `<option value=\"${i}\">${p.label}</option>`).join('')}\n </select>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">llm_model</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.llm.hint_model')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"llm-model\" type=\"text\"\n value=\"${esc(config.llm_model || '')}\" placeholder=\"openai/gpt-4o-mini\">\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">llm_base_url</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.llm.hint_base_url')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"llm-base-url\" type=\"text\"\n value=\"${esc(config.llm_base_url || '')}\" placeholder=\"https://api.example.com/v1\">\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">llm_api_key</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.llm.hint_api_key')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"llm-api-key\" type=\"password\"\n value=\"${esc(config.llm_api_key || '')}\" placeholder=\"sk-...\">\n <button class=\"btn btn-sm\" id=\"llm-key-eye\" title=\"${t('system.tooltip.show_hide_key')}\" style=\"padding:4px 8px;font-size:16px;line-height:1;\">\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" id=\"eye-icon\">\n <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>\n </svg>\n </button>\n </div>\n </div>\n <div style=\"display:flex;gap:8px;margin-top:16px;align-items:center;\">\n <button class=\"btn btn-primary\" id=\"llm-test\">${t('settings.test')}</button>\n <button class=\"btn\" id=\"llm-save\">${t('common.save')}</button>\n <span id=\"llm-status\" style=\"font-size:13px;margin-left:8px;\"></span>\n </div>\n <div id=\"llm-log\" class=\"hidden\" style=\"margin-top:12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;font-family:var(--font-mono);font-size:12px;line-height:1.6;max-height:200px;overflow-y:auto;white-space:pre-wrap;\"></div>\n `;\n\n const presetSelect = section.querySelector('#llm-preset');\n const modelInput = section.querySelector('#llm-model');\n const urlInput = section.querySelector('#llm-base-url');\n const keyInput = section.querySelector('#llm-api-key');\n const eyeBtn = section.querySelector('#llm-key-eye');\n const testBtn = section.querySelector('#llm-test');\n const saveBtn = section.querySelector('#llm-save');\n const statusEl = section.querySelector('#llm-status');\n const logEl = section.querySelector('#llm-log');\n\n let keyRevealed = false;\n eyeBtn.addEventListener('click', async () => {\n if (keyRevealed) {\n keyInput.type = 'password';\n keyRevealed = false;\n eyeBtn.style.color = '';\n } else {\n try {\n const res = await api.revealConfigValue('llm_api_key');\n if (res.value) {\n keyInput.type = 'text';\n keyInput.value = res.value;\n keyRevealed = true;\n eyeBtn.style.color = 'var(--accent)';\n } else {\n keyInput.type = 'text';\n keyInput.placeholder = t('system.placeholder.not_set');\n keyRevealed = true;\n eyeBtn.style.color = 'var(--accent)';\n }\n } catch (e) {\n error(t('system.toast.reveal_key_failed') + e.message);\n }\n }\n });\n\n const currentModel = config.llm_model || '';\n let matchIdx = LLM_PRESETS.length - 1;\n for (let i = 0; i < LLM_PRESETS.length - 1; i++) {\n if (currentModel === LLM_PRESETS[i].model) { matchIdx = i; break; }\n }\n presetSelect.value = matchIdx;\n\n presetSelect.addEventListener('change', () => {\n const p = LLM_PRESETS[presetSelect.value];\n if (p.model) modelInput.value = p.model;\n if (p.url !== undefined) urlInput.value = p.url;\n keyInput.placeholder = p.placeholder_key || t('system.llm.placeholder_api_key');\n });\n\n testBtn.addEventListener('click', async () => {\n const model = modelInput.value.trim();\n const base_url = urlInput.value.trim() || null;\n const api_key = keyInput.value.trim() || null;\n\n if (!model) { error(t('system.toast.model_required')); return; }\n if (model.startsWith('http://') || model.startsWith('https://')) {\n error(t('system.toast.model_not_url'));\n return;\n }\n if (!api_key) { error(t('system.toast.api_key_required')); return; }\n\n statusEl.innerHTML = `<span style=\"color:var(--accent)\">${t('settings.testing')}</span>`;\n logEl.classList.remove('hidden');\n logEl.textContent = `${t('system.log.testing_connection')}\\n${t('system.log.model')}: ${model}\\n${t('system.log.base_url')}: ${base_url || t('system.log.default')}\\n`;\n\n try {\n const res = await api.testLLM(model, base_url, api_key);\n if (res.success) {\n statusEl.innerHTML = `<span style=\"color:var(--accent-green)\">✓ ${t('settings.success')}</span>`;\n logEl.textContent += `\\n[OK] ${t('system.log.model_responded')}: \"${res.response}\"\\n${t('system.log.actual_model')}: ${res.model}`;\n logEl.style.borderColor = 'var(--accent-green)';\n } else {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('settings.failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${res.error}`;\n logEl.style.borderColor = 'var(--accent-red)';\n }\n } catch (e) {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('settings.failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${e.message}`;\n logEl.style.borderColor = 'var(--accent-red)';\n }\n });\n\n saveBtn.addEventListener('click', async () => {\n try {\n const model = modelInput.value.trim();\n const base_url = urlInput.value.trim();\n const api_key = keyInput.value.trim();\n\n if (model) await api.updateConfig('llm_model', model);\n await api.updateConfig('llm_base_url', base_url || 'null');\n if (api_key) await api.updateConfig('llm_api_key', api_key);\n success(t('system.toast.llm_saved'));\n } catch (e) {\n error(e.message);\n }\n });\n\n return section;\n}\n\n/* ====================================================================\n Embedding Section — Local vs API, with a Custom HTTP (JSON) sub-mode\n ==================================================================== */\nfunction buildEmbeddingSection(config) {\n const section = document.createElement('div');\n section.className = 'card mb-4';\n\n const isEnabled = config.embedding_enabled !== false;\n const provider = config.embedding_provider === 'api' ? 'api' : 'local';\n const apiMode = config.embedding_api_mode === 'custom' ? 'custom' : 'litellm';\n\n const localModelInit = provider === 'local' ? (config.embedding_model || '') : '';\n const apiModelInit = provider === 'api' && apiMode === 'litellm' ? (config.embedding_model || '') : '';\n\n const httpMethod = config.embedding_http_method || EMB_HTTP_DEFAULTS.method;\n const httpUrl = config.embedding_http_url || '';\n const httpHeaders = config.embedding_http_headers || EMB_HTTP_DEFAULTS.headers;\n const httpBody = config.embedding_http_body || EMB_HTTP_DEFAULTS.body;\n const httpRespPath = config.embedding_http_response_path || EMB_HTTP_DEFAULTS.response_path;\n const methods = ['POST', 'GET', 'PUT', 'PATCH'];\n\n section.innerHTML = `\n <div class=\"flex-between mb-4\">\n <h3 style=\"font-size:14px;font-weight:600;\">\n <span style=\"margin-right:6px\">🔬</span>${t('settings.embedding_title')}\n <span id=\"emb-status-badge\" style=\"font-size:10px;margin-left:8px\"></span>\n </h3>\n </div>\n <div style=\"background:var(--bg-tertiary);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:13px;color:var(--text-secondary);line-height:1.6;\">\n ${t('settings.emb.guide')}\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_enabled</span>\n </div>\n <div class=\"setting-input-wrap\">\n <div class=\"toggle ${isEnabled ? 'on' : ''}\" id=\"emb-enabled-toggle\">\n <input type=\"checkbox\" ${isEnabled ? 'checked' : ''} style=\"display:none\">\n </div>\n </div>\n </div>\n <div id=\"emb-fields\" ${!isEnabled ? 'style=\"opacity:0.5;pointer-events:none\"' : ''}>\n <div class=\"form-group\">\n <label class=\"form-label\">${t('settings.provider')}</label>\n <div class=\"seg\" id=\"emb-provider-seg\">\n <button type=\"button\" class=\"seg-btn\" data-provider=\"local\">${t('system.emb.provider_local')}</button>\n <button type=\"button\" class=\"seg-btn\" data-provider=\"api\">${t('system.emb.provider_api')}</button>\n </div>\n </div>\n\n <div id=\"emb-local-panel\">\n <div class=\"form-group\">\n <label class=\"form-label\">${t('system.emb.label_preset')}</label>\n <select class=\"form-select\" id=\"emb-local-preset\" style=\"max-width:480px\">\n ${EMB_LOCAL_PRESETS.map((p, i) => `<option value=\"${i}\">${p.label}</option>`).join('')}\n </select>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_model</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_local_model')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-local-model\" type=\"text\"\n value=\"${esc(localModelInit)}\" placeholder=\"BAAI/bge-large-en-v1.5\">\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">hf_endpoint</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_hf_endpoint')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-hf-endpoint\" type=\"text\"\n value=\"${esc(config.hf_endpoint || '')}\" placeholder=\"https://hf-mirror.com\">\n </div>\n </div>\n </div>\n\n <div id=\"emb-api-panel\">\n <div class=\"form-group\">\n <label class=\"form-label\">${t('system.emb.label_api_mode')}</label>\n <div class=\"seg\" id=\"emb-apimode-seg\">\n <button type=\"button\" class=\"seg-btn\" data-mode=\"litellm\">${t('system.emb.mode_standard')}</button>\n <button type=\"button\" class=\"seg-btn\" data-mode=\"custom\">${t('system.emb.mode_custom_http')}</button>\n </div>\n </div>\n\n <div id=\"emb-api-standard\">\n <div class=\"form-group\">\n <label class=\"form-label\">${t('system.emb.label_preset')}</label>\n <select class=\"form-select\" id=\"emb-api-preset\" style=\"max-width:480px\">\n ${EMB_API_PRESETS.map((p, i) => `<option value=\"${i}\">${p.label}</option>`).join('')}\n </select>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_model</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_api_model')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-api-model\" type=\"text\"\n value=\"${esc(apiModelInit)}\" placeholder=\"openai/text-embedding-3-small\">\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_base_url</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_base_url')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-base-url\" type=\"text\"\n value=\"${esc(config.embedding_base_url || '')}\" placeholder=\"https://api.openai.com/v1\">\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_api_key</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_api_key')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-api-key\" type=\"password\"\n value=\"${esc(config.embedding_api_key || '')}\" placeholder=\"sk-...\">\n <button class=\"btn btn-sm\" id=\"emb-key-eye\" title=\"${t('system.tooltip.show_hide')}\" style=\"padding:4px 8px;font-size:16px;line-height:1;\">\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>\n </svg>\n </button>\n </div>\n </div>\n </div>\n\n <div id=\"emb-api-custom\">\n <div style=\"background:var(--bg-tertiary);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:12.5px;color:var(--text-secondary);line-height:1.6;\">\n ${t('settings.emb.custom_help')}\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_http_method</span>\n </div>\n <div class=\"setting-input-wrap\">\n <select class=\"form-select setting-input\" id=\"emb-http-method\" style=\"max-width:140px\">\n ${methods.map((m) => `<option value=\"${m}\" ${m === httpMethod ? 'selected' : ''}>${m}</option>`).join('')}\n </select>\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_http_url</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-http-url\" type=\"text\"\n value=\"${esc(httpUrl)}\" placeholder=\"${esc(EMB_HTTP_DEFAULTS.url)}\">\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\" style=\"display:flex;align-items:center;gap:8px;\">\n <span class=\"setting-key\">embedding_http_headers</span>\n <button class=\"btn btn-sm\" id=\"emb-http-headers-eye\" title=\"${t('system.tooltip.reveal_headers')}\" style=\"padding:2px 6px;font-size:14px;line-height:1;\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>\n </svg>\n </button>\n </label>\n <textarea class=\"form-textarea\" id=\"emb-http-headers\" spellcheck=\"false\"\n style=\"font-family:var(--font-mono);font-size:12.5px\" placeholder='{\"Authorization\": \"Bearer ...\"}'>${esc(httpHeaders)}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\"><span class=\"setting-key\">embedding_http_body</span> <span class=\"text-muted text-sm\">${t('system.emb.hint_http_body')}</span></label>\n <textarea class=\"form-textarea\" id=\"emb-http-body\" spellcheck=\"false\"\n style=\"font-family:var(--font-mono);font-size:12.5px;min-height:120px\">${esc(httpBody)}</textarea>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_http_response_path</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_response_path')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-http-response-path\" type=\"text\"\n value=\"${esc(httpRespPath)}\" placeholder=\"data.*.embedding\">\n </div>\n </div>\n </div>\n </div>\n\n <div style=\"display:flex;gap:8px;margin-top:16px;align-items:center;\">\n <button class=\"btn btn-primary\" id=\"emb-test\">${t('system.emb.test_button')}</button>\n <button class=\"btn\" id=\"emb-save\">${t('common.save')}</button>\n <span id=\"emb-status\" style=\"font-size:13px;margin-left:8px;\"></span>\n </div>\n <div id=\"emb-progress\" class=\"hidden\" style=\"margin-top:12px;\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;font-size:12px;color:var(--text-secondary);margin-bottom:4px;\">\n <span id=\"emb-progress-label\">${t('system.emb.progress.downloading')}</span>\n <span id=\"emb-progress-pct\" style=\"font-family:var(--font-mono);\"></span>\n </div>\n <div style=\"background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);height:8px;overflow:hidden;\">\n <div id=\"emb-progress-fill\" style=\"background:var(--accent);height:100%;width:0;transition:width 200ms ease;\"></div>\n </div>\n <div id=\"emb-progress-detail\" style=\"font-size:11px;color:var(--text-secondary);margin-top:4px;font-family:var(--font-mono);min-height:14px;\"></div>\n </div>\n <div id=\"emb-log\" class=\"hidden\" style=\"margin-top:12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;font-family:var(--font-mono);font-size:12px;line-height:1.6;max-height:200px;overflow-y:auto;white-space:pre-wrap;\"></div>\n </div>\n `;\n\n const enabledToggle = section.querySelector('#emb-enabled-toggle');\n const fieldsDiv = section.querySelector('#emb-fields');\n const providerSeg = section.querySelector('#emb-provider-seg');\n const apiModeSeg = section.querySelector('#emb-apimode-seg');\n const localPanel = section.querySelector('#emb-local-panel');\n const apiPanel = section.querySelector('#emb-api-panel');\n const standardPanel = section.querySelector('#emb-api-standard');\n const customPanel = section.querySelector('#emb-api-custom');\n\n const localPresetSelect = section.querySelector('#emb-local-preset');\n const localModelInput = section.querySelector('#emb-local-model');\n const hfEndpointInput = section.querySelector('#emb-hf-endpoint');\n const apiPresetSelect = section.querySelector('#emb-api-preset');\n const apiModelInput = section.querySelector('#emb-api-model');\n const baseUrlInput = section.querySelector('#emb-base-url');\n const apiKeyInput = section.querySelector('#emb-api-key');\n const keyEyeBtn = section.querySelector('#emb-key-eye');\n\n const methodSelect = section.querySelector('#emb-http-method');\n const httpUrlInput = section.querySelector('#emb-http-url');\n const httpHeadersInput = section.querySelector('#emb-http-headers');\n const httpHeadersEye = section.querySelector('#emb-http-headers-eye');\n const httpBodyInput = section.querySelector('#emb-http-body');\n const responsePathInput = section.querySelector('#emb-http-response-path');\n\n const testBtn = section.querySelector('#emb-test');\n const saveBtn = section.querySelector('#emb-save');\n const statusEl = section.querySelector('#emb-status');\n const logEl = section.querySelector('#emb-log');\n const progressEl = section.querySelector('#emb-progress');\n const progressFillEl = section.querySelector('#emb-progress-fill');\n const progressPctEl = section.querySelector('#emb-progress-pct');\n const progressLabelEl = section.querySelector('#emb-progress-label');\n const progressDetailEl = section.querySelector('#emb-progress-detail');\n\n let currentProvider = provider;\n let currentApiMode = apiMode;\n\n function fmtBytes(n) {\n if (!n) return '0 B';\n const u = ['B', 'KB', 'MB', 'GB', 'TB'];\n let i = 0;\n while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }\n return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;\n }\n\n function renderProgress(task) {\n progressEl.classList.remove('hidden');\n const total = task.bytes_total || 0;\n const done = task.bytes_done || 0;\n const pct = total > 0 ? Math.min(100, (done / total) * 100) : 0;\n progressFillEl.style.width = pct + '%';\n if (task.status === 'downloading') {\n progressLabelEl.textContent = t('system.emb.progress.downloading');\n progressPctEl.textContent = total > 0 ? `${pct.toFixed(1)}%` : '';\n progressDetailEl.textContent = total > 0\n ? `${fmtBytes(done)} / ${fmtBytes(total)}${task.current_file ? ' · ' + task.current_file : ''}`\n : (task.current_file || t('system.emb.progress.resolving'));\n } else if (task.status === 'verifying') {\n progressLabelEl.textContent = t('system.emb.progress.verifying');\n progressPctEl.textContent = '';\n progressFillEl.style.width = '100%';\n progressDetailEl.textContent = t('system.emb.progress.verifying_detail');\n } else if (task.status === 'done') {\n progressLabelEl.textContent = t('system.emb.progress.ready');\n progressPctEl.textContent = '';\n progressFillEl.style.background = 'var(--accent-green)';\n progressFillEl.style.width = '100%';\n progressDetailEl.textContent = `dimension=${task.dimension}`;\n } else if (task.status === 'failed') {\n progressLabelEl.textContent = t('system.emb.progress.failed');\n progressFillEl.style.background = 'var(--accent-red)';\n progressDetailEl.textContent = task.error || '';\n }\n }\n\n function resetProgress() {\n progressEl.classList.add('hidden');\n progressFillEl.style.background = 'var(--accent)';\n progressFillEl.style.width = '0';\n progressPctEl.textContent = '';\n progressDetailEl.textContent = '';\n }\n\n async function pollDownload(taskId) {\n while (true) {\n let task;\n try {\n task = await api.getTestEmbeddingStatus(taskId);\n } catch (e) {\n throw new Error(t('system.emb.poll_lost_connection') + e.message);\n }\n renderProgress(task);\n if (task.status === 'done' || task.status === 'failed') return task;\n await new Promise(r => setTimeout(r, 500));\n }\n }\n\n enabledToggle.addEventListener('click', () => {\n const cb = enabledToggle.querySelector('input');\n cb.checked = !cb.checked;\n enabledToggle.classList.toggle('on', cb.checked);\n fieldsDiv.style.opacity = cb.checked ? '' : '0.5';\n fieldsDiv.style.pointerEvents = cb.checked ? '' : 'none';\n });\n\n function setProvider(p) {\n currentProvider = p;\n providerSeg.querySelectorAll('.seg-btn').forEach(b => b.classList.toggle('active', b.dataset.provider === p));\n localPanel.style.display = p === 'local' ? '' : 'none';\n apiPanel.style.display = p === 'api' ? '' : 'none';\n }\n function setApiMode(m) {\n currentApiMode = m;\n apiModeSeg.querySelectorAll('.seg-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === m));\n standardPanel.style.display = m === 'litellm' ? '' : 'none';\n customPanel.style.display = m === 'custom' ? '' : 'none';\n }\n providerSeg.querySelectorAll('.seg-btn').forEach(b => b.addEventListener('click', () => setProvider(b.dataset.provider)));\n apiModeSeg.querySelectorAll('.seg-btn').forEach(b => b.addEventListener('click', () => setApiMode(b.dataset.mode)));\n setProvider(currentProvider);\n setApiMode(currentApiMode);\n\n let localMatch = EMB_LOCAL_PRESETS.length - 1;\n for (let i = 0; i < EMB_LOCAL_PRESETS.length - 1; i++) {\n if (localModelInit === EMB_LOCAL_PRESETS[i].model) { localMatch = i; break; }\n }\n localPresetSelect.value = localMatch;\n localPresetSelect.addEventListener('change', () => {\n const p = EMB_LOCAL_PRESETS[localPresetSelect.value];\n if (p.model) localModelInput.value = p.model;\n });\n\n let apiMatch = EMB_API_PRESETS.length - 1;\n for (let i = 0; i < EMB_API_PRESETS.length - 1; i++) {\n if (apiModelInit === EMB_API_PRESETS[i].model) { apiMatch = i; break; }\n }\n apiPresetSelect.value = apiMatch;\n apiPresetSelect.addEventListener('change', () => {\n const p = EMB_API_PRESETS[apiPresetSelect.value];\n if (p.model) apiModelInput.value = p.model;\n if (p.url !== undefined) baseUrlInput.value = p.url;\n });\n\n let keyRevealed = false;\n keyEyeBtn.addEventListener('click', async () => {\n if (keyRevealed) {\n apiKeyInput.type = 'password';\n keyRevealed = false;\n keyEyeBtn.style.color = '';\n } else {\n try {\n const res = await api.revealConfigValue('embedding_api_key');\n apiKeyInput.type = 'text';\n apiKeyInput.value = res.value || '';\n apiKeyInput.placeholder = res.value ? '' : t('system.placeholder.not_set');\n keyRevealed = true;\n keyEyeBtn.style.color = 'var(--accent)';\n } catch (e) {\n error(t('system.toast.reveal_key_failed') + e.message);\n }\n }\n });\n\n httpHeadersEye.addEventListener('click', async () => {\n try {\n const res = await api.revealConfigValue('embedding_http_headers');\n if (res.value) httpHeadersInput.value = res.value;\n httpHeadersEye.style.color = 'var(--accent)';\n } catch (e) {\n error(t('system.toast.reveal_headers_failed') + e.message);\n }\n });\n\n async function runTest(params, logHeader) {\n resetProgress();\n statusEl.innerHTML = `<span style=\"color:var(--accent)\">${t('settings.testing')}</span>`;\n logEl.classList.remove('hidden');\n logEl.style.borderColor = 'var(--border)';\n logEl.textContent = logHeader;\n testBtn.disabled = true;\n try {\n const res = await api.testEmbedding(params);\n if (res.async && res.task_id) {\n renderProgress({ status: 'downloading', bytes_done: 0, bytes_total: 0, current_file: '' });\n logEl.textContent += `\\n[INFO] ${t('system.emb.log.download_started')} (task_id=${res.task_id}).\\n`;\n let final;\n try {\n final = await pollDownload(res.task_id);\n } catch (e) {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('system.emb.status.polling_failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${e.message}`;\n logEl.style.borderColor = 'var(--accent-red)';\n return;\n }\n if (final.status === 'done') {\n statusEl.innerHTML = `<span style=\"color:var(--accent-green)\">✓ ${t('system.emb.status.ok_dimension')}=${final.dimension}</span>`;\n logEl.textContent += `\\n[OK] ${t('system.emb.log.downloaded_verified')}, dimension=${final.dimension}`;\n logEl.style.borderColor = 'var(--accent-green)';\n } else {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('system.emb.status.download_failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${final.error || t('system.emb.log.unknown_error')}`;\n logEl.style.borderColor = 'var(--accent-red)';\n }\n } else if (res.success) {\n statusEl.innerHTML = `<span style=\"color:var(--accent-green)\">✓ ${t('system.emb.status.ok_dimension')}=${res.dimension}</span>`;\n logEl.textContent += `\\n[OK] ${res.message}`;\n logEl.style.borderColor = 'var(--accent-green)';\n } else {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('settings.failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${res.error}`;\n logEl.style.borderColor = 'var(--accent-red)';\n }\n } catch (e) {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('system.emb.status.request_failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${e.message}`;\n logEl.style.borderColor = 'var(--accent-red)';\n } finally {\n testBtn.disabled = false;\n }\n }\n\n testBtn.addEventListener('click', async () => {\n if (currentProvider === 'local') {\n const model = localModelInput.value.trim();\n if (!model) { error(t('system.toast.model_required')); return; }\n await runTest({ provider: 'local', model }, `${t('system.emb.log.testing')}\\n${t('system.log.provider')}: local\\n${t('system.log.model')}: ${model}\\n`);\n } else if (currentApiMode === 'custom') {\n const http_url = httpUrlInput.value.trim();\n const http_body = httpBodyInput.value;\n if (!http_url) { error(t('system.toast.url_required_custom')); return; }\n if (!http_body.trim()) { error(t('system.toast.body_required_custom')); return; }\n await runTest(\n {\n provider: 'api',\n api_mode: 'custom',\n http_method: methodSelect.value,\n http_url,\n http_headers: httpHeadersInput.value,\n http_body,\n http_response_path: responsePathInput.value.trim() || 'data.*.embedding',\n },\n `${t('system.emb.log.testing')}\\n${t('system.log.provider')}: api (custom HTTP)\\n${methodSelect.value} ${http_url}\\n`\n );\n } else {\n const model = apiModelInput.value.trim();\n const base_url = baseUrlInput.value.trim() || null;\n const api_key = apiKeyInput.value.trim() || null;\n if (!model) { error(t('system.toast.model_required')); return; }\n if (!base_url) { error(t('system.toast.base_url_required_api')); return; }\n await runTest(\n { provider: 'api', api_mode: 'litellm', model, base_url, api_key },\n `${t('system.emb.log.testing')}\\n${t('system.log.provider')}: api (litellm)\\n${t('system.log.model')}: ${model}\\n${t('system.log.base_url')}: ${base_url}\\n`\n );\n }\n });\n\n async function refreshBadge() {\n const badge = section.querySelector('#emb-status-badge');\n try {\n const st = await api.getEmbeddingStatus();\n if (!st.enabled) {\n badge.className = 'tag tag-yellow';\n badge.textContent = t('system.badge.disabled');\n } else if (st.provider === 'api') {\n badge.className = 'tag tag-green';\n badge.textContent = st.api_mode === 'custom' ? t('system.badge.api_custom_http') : t('system.badge.api');\n } else if (st.cached) {\n badge.className = 'tag tag-green';\n badge.textContent = t('system.badge.local_cached');\n } else {\n badge.className = 'tag tag-yellow';\n badge.textContent = t('system.badge.local_not_downloaded');\n }\n } catch { /* ignore */ }\n }\n\n saveBtn.addEventListener('click', async () => {\n try {\n const enabled = enabledToggle.querySelector('input').checked;\n let needsRestart = false;\n const collect = (res) => { if (res && res.restart_required) needsRestart = true; };\n\n collect(await api.updateConfig('embedding_enabled', String(enabled)));\n collect(await api.updateConfig('embedding_provider', currentProvider));\n\n if (currentProvider === 'local') {\n const model = localModelInput.value.trim();\n if (model) collect(await api.updateConfig('embedding_model', model));\n collect(await api.updateConfig('hf_endpoint', hfEndpointInput.value.trim() || 'null'));\n } else {\n collect(await api.updateConfig('embedding_api_mode', currentApiMode));\n if (currentApiMode === 'custom') {\n collect(await api.updateConfig('embedding_http_method', methodSelect.value || 'POST'));\n collect(await api.updateConfig('embedding_http_url', httpUrlInput.value.trim() || 'null'));\n const headers = httpHeadersInput.value;\n if (headers && !headers.includes('****')) {\n collect(await api.updateConfig('embedding_http_headers', headers.trim() || 'null'));\n }\n collect(await api.updateConfig('embedding_http_body', httpBodyInput.value.trim() || 'null'));\n collect(await api.updateConfig('embedding_http_response_path', responsePathInput.value.trim() || 'data.*.embedding'));\n } else {\n const model = apiModelInput.value.trim();\n if (model) collect(await api.updateConfig('embedding_model', model));\n collect(await api.updateConfig('embedding_base_url', baseUrlInput.value.trim() || 'null'));\n const api_key = apiKeyInput.value.trim();\n if (api_key && !api_key.includes('****')) {\n collect(await api.updateConfig('embedding_api_key', api_key));\n }\n }\n }\n\n await refreshBadge();\n success(t('system.toast.emb_saved'));\n if (needsRestart) {\n offerRestart({ onRestarted: () => window.location.reload() });\n }\n } catch (e) {\n error(e.message);\n }\n });\n\n refreshBadge();\n\n return section;\n}\n"}, "af971a3dcae14a79a9985bf6d2cdb40be6a422a2": {"t": 1785766757.364704, "n": 39705, "h": "65267e286d170e1a", "c": "/**\n * System — infrastructure configuration (LLM · Embedding · Storage · Server).\n *\n * The memory-lifecycle config (recall pipeline, consolidation, forgetting) now\n * lives on the Activate / Consolidate / Forget pages; this page keeps only the\n * provider/storage/server plumbing. LLM and Embedding each have a guided setup\n * with a connection test; Storage and Server are plain key/value groups.\n */\n\nimport * as api from '../api.js';\nimport { t } from '../i18n.js';\nimport { success, error } from './toast.js';\nimport { buildGenericSection, offerRestart, esc } from './config-section.js';\n\n/* --- LLM provider presets for the guided UI --- */\nconst LLM_PRESETS = [\n { label: 'OpenAI', model: 'openai/gpt-4o-mini', url: '', placeholder_key: 'sk-...' },\n { label: 'Anthropic (Claude)', model: 'anthropic/claude-3-haiku-20240307', url: '', placeholder_key: 'sk-ant-...' },\n { label: 'Qwen (Alibaba)', model: 'openai/qwen-plus', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', placeholder_key: 'sk-...' },\n { label: 'GLM (Zhipu)', model: 'openai/glm-4-flash', url: 'https://open.bigmodel.cn/api/paas/v4', placeholder_key: '...' },\n { label: 'DeepSeek', model: 'openai/deepseek-chat', url: 'https://api.deepseek.com/v1', placeholder_key: 'sk-...' },\n { label: 'Kimi (Moonshot)', model: 'openai/moonshot-v1-8k', url: 'https://api.moonshot.cn/v1', placeholder_key: 'sk-...' },\n { label: 'Custom / Self-hosted', model: '', url: '', placeholder_key: '' },\n];\n\n/* --- Embedding provider presets, split by mode so Local and API are distinct --- */\nconst EMB_LOCAL_PRESETS = [\n { label: 'bge-large-en-v1.5 (English, 1024d)', model: 'BAAI/bge-large-en-v1.5', dim: 1024 },\n { label: 'bge-m3 (Multilingual, 1024d, 2.2GB)', model: 'BAAI/bge-m3', dim: 1024 },\n { label: 'all-MiniLM-L6-v2 (Fast, 384d, 87MB)', model: 'sentence-transformers/all-MiniLM-L6-v2', dim: 384 },\n { label: 'multilingual-e5-small (Multi, 384d, 470MB)', model: 'intfloat/multilingual-e5-small', dim: 384 },\n { label: 'Custom model…', model: '', dim: '' },\n];\nconst EMB_API_PRESETS = [\n { label: 'OpenAI — text-embedding-3-small', model: 'openai/text-embedding-3-small', url: 'https://api.openai.com/v1', dim: 1536 },\n { label: 'Cohere — embed-multilingual-v3.0', model: 'cohere/embed-multilingual-v3.0', url: 'https://api.cohere.com/v1', dim: 1024 },\n { label: 'Qwen (DashScope)', model: 'openai/text-embedding-v3', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', dim: 1024 },\n { label: 'Custom / Self-hosted', model: '', url: '', dim: '' },\n];\n\nconst EMB_HTTP_DEFAULTS = {\n method: 'POST',\n url: 'https://api.example.com/v1/embeddings',\n headers: '{\\n \"Authorization\": \"Bearer YOUR_KEY\",\\n \"Content-Type\": \"application/json\"\\n}',\n body: '{\\n \"model\": \"your-embedding-model\",\\n \"input\": {{input}}\\n}',\n response_path: 'data.*.embedding',\n};\n\nconst GROUP_STORAGE = {\n titleKey: 'settings.group.storage',\n icon: '🗃',\n keys: ['storage_type', 'pg_url', 'pg_pool_min', 'pg_pool_max'],\n};\nconst GROUP_WORKSPACE = {\n titleKey: 'settings.group.workspace',\n icon: '📁',\n keys: ['home'],\n};\nconst GROUP_SERVER = {\n titleKey: 'settings.group.server',\n icon: '🖥',\n keys: ['host', 'port'],\n};\n\nconst TABS = [\n { id: 'llm', labelKey: 'settings.tab.llm', build: (config) => [buildLLMSection(config)] },\n { id: 'embedding', labelKey: 'settings.tab.embedding', build: (config) => [buildEmbeddingSection(config)] },\n {\n id: 'storage',\n labelKey: 'settings.tab.storage',\n build: (config) => [buildGenericSection(GROUP_STORAGE, config), buildGenericSection(GROUP_WORKSPACE, config)],\n },\n { id: 'server', labelKey: 'settings.tab.server', build: (config) => [buildGenericSection(GROUP_SERVER, config)] },\n];\n\nexport async function renderSystem(root, sub) {\n root.innerHTML = `\n <div class=\"page-header\">\n <h1 class=\"page-title\">${t('system.title')}</h1>\n <p class=\"page-subtitle\">${t('system.subtitle')}</p>\n </div>\n <div class=\"console-tabs\" id=\"system-tabs\"></div>\n <div id=\"system-panel\">${t('common.loading')}</div>\n `;\n\n let config;\n try {\n config = await api.getConfig();\n } catch (e) {\n root.querySelector('#system-panel').innerHTML = `<div class=\"empty-state\">${e.message}</div>`;\n return;\n }\n\n const tabBar = root.querySelector('#system-tabs');\n const panel = root.querySelector('#system-panel');\n\n // Deep-link (#system/embedding) wins; else the remembered tab; else the first.\n let active = sub && TABS.some((x) => x.id === sub) ? sub : localStorage.getItem('hebb-system-tab');\n if (!TABS.some((tab) => tab.id === active)) active = TABS[0].id;\n\n tabBar.innerHTML = TABS.map(\n (tab) => `<button class=\"console-tab\" data-tab=\"${tab.id}\">${t(tab.labelKey)}</button>`\n ).join('');\n\n function showTab(id) {\n active = id;\n localStorage.setItem('hebb-system-tab', id);\n if (location.hash !== `#system/${id}`) history.replaceState(null, '', `#system/${id}`);\n tabBar.querySelectorAll('.console-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === id));\n panel.innerHTML = '';\n const tab = TABS.find((x) => x.id === id) || TABS[0];\n for (const section of tab.build(config)) panel.appendChild(section);\n }\n\n tabBar.querySelectorAll('.console-tab').forEach((btn) => {\n btn.addEventListener('click', () => showTab(btn.dataset.tab));\n });\n\n showTab(active);\n}\n\n/* ====================================================================\n LLM Section — guided setup with provider presets and test button\n ==================================================================== */\nfunction buildLLMSection(config) {\n const section = document.createElement('div');\n section.className = 'card mb-4';\n\n const isConfigured = Boolean(config.llm_model);\n\n section.innerHTML = `\n <div class=\"flex-between mb-4\">\n <h3 style=\"font-size:14px;font-weight:600;\">\n <span style=\"margin-right:6px\">🤖</span>${t('settings.llm_title')}\n ${isConfigured\n ? `<span class=\"tag tag-green\" style=\"font-size:10px;margin-left:8px\">${t('settings.configured')}</span>`\n : `<span class=\"tag tag-yellow\" style=\"font-size:10px;margin-left:8px\">${t('settings.not_configured')}</span>`}\n </h3>\n </div>\n ${!isConfigured ? `\n <div style=\"background:var(--bg-tertiary);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:13px;color:var(--text-secondary);line-height:1.6;\">\n ${t('settings.llm_guide')}\n </div>` : ''}\n <div class=\"form-group\">\n <label class=\"form-label\">${t('settings.provider')}</label>\n <select class=\"form-select\" id=\"llm-preset\" style=\"max-width:320px\">\n ${LLM_PRESETS.map((p, i) => `<option value=\"${i}\">${p.label}</option>`).join('')}\n </select>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">llm_model</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.llm.hint_model')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"llm-model\" type=\"text\"\n value=\"${esc(config.llm_model || '')}\" placeholder=\"openai/gpt-4o-mini\">\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">llm_base_url</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.llm.hint_base_url')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"llm-base-url\" type=\"text\"\n value=\"${esc(config.llm_base_url || '')}\" placeholder=\"https://api.example.com/v1\">\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">llm_api_key</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.llm.hint_api_key')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"llm-api-key\" type=\"password\"\n value=\"${esc(config.llm_api_key || '')}\" placeholder=\"sk-...\">\n <button class=\"btn btn-sm\" id=\"llm-key-eye\" title=\"${t('system.tooltip.show_hide_key')}\" style=\"padding:4px 8px;font-size:16px;line-height:1;\">\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" id=\"eye-icon\">\n <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>\n </svg>\n </button>\n </div>\n </div>\n <div style=\"display:flex;gap:8px;margin-top:16px;align-items:center;\">\n <button class=\"btn btn-primary\" id=\"llm-test\">${t('settings.test')}</button>\n <button class=\"btn\" id=\"llm-save\">${t('common.save')}</button>\n <span id=\"llm-status\" style=\"font-size:13px;margin-left:8px;\"></span>\n </div>\n <div id=\"llm-log\" class=\"hidden\" style=\"margin-top:12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;font-family:var(--font-mono);font-size:12px;line-height:1.6;max-height:200px;overflow-y:auto;white-space:pre-wrap;\"></div>\n `;\n\n const presetSelect = section.querySelector('#llm-preset');\n const modelInput = section.querySelector('#llm-model');\n const urlInput = section.querySelector('#llm-base-url');\n const keyInput = section.querySelector('#llm-api-key');\n const eyeBtn = section.querySelector('#llm-key-eye');\n const testBtn = section.querySelector('#llm-test');\n const saveBtn = section.querySelector('#llm-save');\n const statusEl = section.querySelector('#llm-status');\n const logEl = section.querySelector('#llm-log');\n\n let keyRevealed = false;\n eyeBtn.addEventListener('click', async () => {\n if (keyRevealed) {\n keyInput.type = 'password';\n keyRevealed = false;\n eyeBtn.style.color = '';\n } else {\n try {\n const res = await api.revealConfigValue('llm_api_key');\n if (res.value) {\n keyInput.type = 'text';\n keyInput.value = res.value;\n keyRevealed = true;\n eyeBtn.style.color = 'var(--accent)';\n } else {\n keyInput.type = 'text';\n keyInput.placeholder = t('system.placeholder.not_set');\n keyRevealed = true;\n eyeBtn.style.color = 'var(--accent)';\n }\n } catch (e) {\n error(t('system.toast.reveal_key_failed') + e.message);\n }\n }\n });\n\n const currentModel = config.llm_model || '';\n let matchIdx = LLM_PRESETS.length - 1;\n for (let i = 0; i < LLM_PRESETS.length - 1; i++) {\n if (currentModel === LLM_PRESETS[i].model) { matchIdx = i; break; }\n }\n presetSelect.value = matchIdx;\n\n presetSelect.addEventListener('change', () => {\n const p = LLM_PRESETS[presetSelect.value];\n if (p.model) modelInput.value = p.model;\n if (p.url !== undefined) urlInput.value = p.url;\n keyInput.placeholder = p.placeholder_key || t('system.llm.placeholder_api_key');\n });\n\n testBtn.addEventListener('click', async () => {\n const model = modelInput.value.trim();\n const base_url = urlInput.value.trim() || null;\n const api_key = keyInput.value.trim() || null;\n\n if (!model) { error(t('system.toast.model_required')); return; }\n if (model.startsWith('http://') || model.startsWith('https://')) {\n error(t('system.toast.model_not_url'));\n return;\n }\n if (!api_key) { error(t('system.toast.api_key_required')); return; }\n\n statusEl.innerHTML = `<span style=\"color:var(--accent)\">${t('settings.testing')}</span>`;\n logEl.classList.remove('hidden');\n logEl.textContent = `${t('system.log.testing_connection')}\\n${t('system.log.model')}: ${model}\\n${t('system.log.base_url')}: ${base_url || t('system.log.default')}\\n`;\n\n try {\n const res = await api.testLLM(model, base_url, api_key);\n if (res.success) {\n statusEl.innerHTML = `<span style=\"color:var(--accent-green)\">✓ ${t('settings.success')}</span>`;\n logEl.textContent += `\\n[OK] ${t('system.log.model_responded')}: \"${res.response}\"\\n${t('system.log.actual_model')}: ${res.model}`;\n logEl.style.borderColor = 'var(--accent-green)';\n } else {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('settings.failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${res.error}`;\n logEl.style.borderColor = 'var(--accent-red)';\n }\n } catch (e) {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('settings.failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${e.message}`;\n logEl.style.borderColor = 'var(--accent-red)';\n }\n });\n\n saveBtn.addEventListener('click', async () => {\n try {\n const model = modelInput.value.trim();\n const base_url = urlInput.value.trim();\n const api_key = keyInput.value.trim();\n\n if (model) await api.updateConfig('llm_model', model);\n await api.updateConfig('llm_base_url', base_url || 'null');\n if (api_key) await api.updateConfig('llm_api_key', api_key);\n success(t('system.toast.llm_saved'));\n } catch (e) {\n error(e.message);\n }\n });\n\n return section;\n}\n\n/* ====================================================================\n Embedding Section — Local vs API, with a Custom HTTP (JSON) sub-mode\n ==================================================================== */\nfunction buildEmbeddingSection(config) {\n const section = document.createElement('div');\n section.className = 'card mb-4';\n\n const isEnabled = config.embedding_enabled !== false;\n const provider = config.embedding_provider === 'api' ? 'api' : 'local';\n const apiMode = config.embedding_api_mode === 'custom' ? 'custom' : 'litellm';\n\n const localModelInit = provider === 'local' ? (config.embedding_model || '') : '';\n const apiModelInit = provider === 'api' && apiMode === 'litellm' ? (config.embedding_model || '') : '';\n\n const httpMethod = config.embedding_http_method || EMB_HTTP_DEFAULTS.method;\n const httpUrl = config.embedding_http_url || '';\n const httpHeaders = config.embedding_http_headers || EMB_HTTP_DEFAULTS.headers;\n const httpBody = config.embedding_http_body || EMB_HTTP_DEFAULTS.body;\n const httpRespPath = config.embedding_http_response_path || EMB_HTTP_DEFAULTS.response_path;\n const methods = ['POST', 'GET', 'PUT', 'PATCH'];\n\n section.innerHTML = `\n <div class=\"flex-between mb-4\">\n <h3 style=\"font-size:14px;font-weight:600;\">\n <span style=\"margin-right:6px\">🔬</span>${t('settings.embedding_title')}\n <span id=\"emb-status-badge\" style=\"font-size:10px;margin-left:8px\"></span>\n </h3>\n </div>\n <div style=\"background:var(--bg-tertiary);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:13px;color:var(--text-secondary);line-height:1.6;\">\n ${t('settings.emb.guide')}\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_enabled</span>\n </div>\n <div class=\"setting-input-wrap\">\n <div class=\"toggle ${isEnabled ? 'on' : ''}\" id=\"emb-enabled-toggle\">\n <input type=\"checkbox\" ${isEnabled ? 'checked' : ''} style=\"display:none\">\n </div>\n </div>\n </div>\n <div id=\"emb-fields\" ${!isEnabled ? 'style=\"opacity:0.5;pointer-events:none\"' : ''}>\n <div class=\"form-group\">\n <label class=\"form-label\">${t('settings.provider')}</label>\n <div class=\"seg\" id=\"emb-provider-seg\">\n <button type=\"button\" class=\"seg-btn\" data-provider=\"local\">${t('system.emb.provider_local')}</button>\n <button type=\"button\" class=\"seg-btn\" data-provider=\"api\">${t('system.emb.provider_api')}</button>\n </div>\n </div>\n\n <div id=\"emb-local-panel\">\n <div class=\"form-group\">\n <label class=\"form-label\">${t('system.emb.label_preset')}</label>\n <select class=\"form-select\" id=\"emb-local-preset\" style=\"max-width:480px\">\n ${EMB_LOCAL_PRESETS.map((p, i) => `<option value=\"${i}\">${p.label}</option>`).join('')}\n </select>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_model</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_local_model')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-local-model\" type=\"text\"\n value=\"${esc(localModelInit)}\" placeholder=\"BAAI/bge-large-en-v1.5\">\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">hf_endpoint</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_hf_endpoint')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-hf-endpoint\" type=\"text\"\n value=\"${esc(config.hf_endpoint || '')}\" placeholder=\"https://hf-mirror.com\">\n </div>\n </div>\n </div>\n\n <div id=\"emb-api-panel\">\n <div class=\"form-group\">\n <label class=\"form-label\">${t('system.emb.label_api_mode')}</label>\n <div class=\"seg\" id=\"emb-apimode-seg\">\n <button type=\"button\" class=\"seg-btn\" data-mode=\"litellm\">${t('system.emb.mode_standard')}</button>\n <button type=\"button\" class=\"seg-btn\" data-mode=\"custom\">${t('system.emb.mode_custom_http')}</button>\n </div>\n </div>\n\n <div id=\"emb-api-standard\">\n <div class=\"form-group\">\n <label class=\"form-label\">${t('system.emb.label_preset')}</label>\n <select class=\"form-select\" id=\"emb-api-preset\" style=\"max-width:480px\">\n ${EMB_API_PRESETS.map((p, i) => `<option value=\"${i}\">${p.label}</option>`).join('')}\n </select>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_model</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_api_model')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-api-model\" type=\"text\"\n value=\"${esc(apiModelInit)}\" placeholder=\"openai/text-embedding-3-small\">\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_base_url</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_base_url')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-base-url\" type=\"text\"\n value=\"${esc(config.embedding_base_url || '')}\" placeholder=\"https://api.openai.com/v1\">\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_api_key</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_api_key')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-api-key\" type=\"password\"\n value=\"${esc(config.embedding_api_key || '')}\" placeholder=\"sk-...\">\n <button class=\"btn btn-sm\" id=\"emb-key-eye\" title=\"${t('system.tooltip.show_hide')}\" style=\"padding:4px 8px;font-size:16px;line-height:1;\">\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>\n </svg>\n </button>\n </div>\n </div>\n </div>\n\n <div id=\"emb-api-custom\">\n <div style=\"background:var(--bg-tertiary);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:12.5px;color:var(--text-secondary);line-height:1.6;\">\n ${t('settings.emb.custom_help')}\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_http_method</span>\n </div>\n <div class=\"setting-input-wrap\">\n <select class=\"form-select setting-input\" id=\"emb-http-method\" style=\"max-width:140px\">\n ${methods.map((m) => `<option value=\"${m}\" ${m === httpMethod ? 'selected' : ''}>${m}</option>`).join('')}\n </select>\n </div>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_http_url</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-http-url\" type=\"text\"\n value=\"${esc(httpUrl)}\" placeholder=\"${esc(EMB_HTTP_DEFAULTS.url)}\">\n </div>\n </div>\n <div class=\"form-group\" style=\"margin-top:12px\">\n <label class=\"form-label\" style=\"display:flex;align-items:center;gap:8px;\">\n <span class=\"setting-key\">embedding_http_headers</span>\n <button class=\"btn btn-sm\" id=\"emb-http-headers-eye\" title=\"${t('system.tooltip.reveal_headers')}\" style=\"padding:2px 6px;font-size:14px;line-height:1;\">\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\">\n <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>\n </svg>\n </button>\n </label>\n <textarea class=\"form-textarea\" id=\"emb-http-headers\" spellcheck=\"false\"\n style=\"font-family:var(--font-mono);font-size:12.5px\" placeholder='{\"Authorization\": \"Bearer ...\"}'>${esc(httpHeaders)}</textarea>\n </div>\n <div class=\"form-group\">\n <label class=\"form-label\"><span class=\"setting-key\">embedding_http_body</span> <span class=\"text-muted text-sm\">${t('system.emb.hint_http_body')}</span></label>\n <textarea class=\"form-textarea\" id=\"emb-http-body\" spellcheck=\"false\"\n style=\"font-family:var(--font-mono);font-size:12.5px;min-height:120px\">${esc(httpBody)}</textarea>\n </div>\n <div class=\"setting-row\">\n <div class=\"setting-label\">\n <span class=\"setting-key\">embedding_http_response_path</span>\n <span class=\"text-muted text-sm\" style=\"display:block;font-family:var(--font);margin-top:2px\">${t('system.emb.hint_response_path')}</span>\n </div>\n <div class=\"setting-input-wrap\">\n <input class=\"form-input setting-input\" id=\"emb-http-response-path\" type=\"text\"\n value=\"${esc(httpRespPath)}\" placeholder=\"data.*.embedding\">\n </div>\n </div>\n </div>\n </div>\n\n <div style=\"display:flex;gap:8px;margin-top:16px;align-items:center;\">\n <button class=\"btn btn-primary\" id=\"emb-test\">${t('system.emb.test_button')}</button>\n <button class=\"btn\" id=\"emb-save\">${t('common.save')}</button>\n <span id=\"emb-status\" style=\"font-size:13px;margin-left:8px;\"></span>\n </div>\n <div id=\"emb-progress\" class=\"hidden\" style=\"margin-top:12px;\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;font-size:12px;color:var(--text-secondary);margin-bottom:4px;\">\n <span id=\"emb-progress-label\">${t('system.emb.progress.downloading')}</span>\n <span id=\"emb-progress-pct\" style=\"font-family:var(--font-mono);\"></span>\n </div>\n <div style=\"background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);height:8px;overflow:hidden;\">\n <div id=\"emb-progress-fill\" style=\"background:var(--accent);height:100%;width:0;transition:width 200ms ease;\"></div>\n </div>\n <div id=\"emb-progress-detail\" style=\"font-size:11px;color:var(--text-secondary);margin-top:4px;font-family:var(--font-mono);min-height:14px;\"></div>\n </div>\n <div id=\"emb-log\" class=\"hidden\" style=\"margin-top:12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:12px;font-family:var(--font-mono);font-size:12px;line-height:1.6;max-height:200px;overflow-y:auto;white-space:pre-wrap;\"></div>\n </div>\n `;\n\n const enabledToggle = section.querySelector('#emb-enabled-toggle');\n const fieldsDiv = section.querySelector('#emb-fields');\n const providerSeg = section.querySelector('#emb-provider-seg');\n const apiModeSeg = section.querySelector('#emb-apimode-seg');\n const localPanel = section.querySelector('#emb-local-panel');\n const apiPanel = section.querySelector('#emb-api-panel');\n const standardPanel = section.querySelector('#emb-api-standard');\n const customPanel = section.querySelector('#emb-api-custom');\n\n const localPresetSelect = section.querySelector('#emb-local-preset');\n const localModelInput = section.querySelector('#emb-local-model');\n const hfEndpointInput = section.querySelector('#emb-hf-endpoint');\n const apiPresetSelect = section.querySelector('#emb-api-preset');\n const apiModelInput = section.querySelector('#emb-api-model');\n const baseUrlInput = section.querySelector('#emb-base-url');\n const apiKeyInput = section.querySelector('#emb-api-key');\n const keyEyeBtn = section.querySelector('#emb-key-eye');\n\n const methodSelect = section.querySelector('#emb-http-method');\n const httpUrlInput = section.querySelector('#emb-http-url');\n const httpHeadersInput = section.querySelector('#emb-http-headers');\n const httpHeadersEye = section.querySelector('#emb-http-headers-eye');\n const httpBodyInput = section.querySelector('#emb-http-body');\n const responsePathInput = section.querySelector('#emb-http-response-path');\n\n const testBtn = section.querySelector('#emb-test');\n const saveBtn = section.querySelector('#emb-save');\n const statusEl = section.querySelector('#emb-status');\n const logEl = section.querySelector('#emb-log');\n const progressEl = section.querySelector('#emb-progress');\n const progressFillEl = section.querySelector('#emb-progress-fill');\n const progressPctEl = section.querySelector('#emb-progress-pct');\n const progressLabelEl = section.querySelector('#emb-progress-label');\n const progressDetailEl = section.querySelector('#emb-progress-detail');\n\n let currentProvider = provider;\n let currentApiMode = apiMode;\n\n function fmtBytes(n) {\n if (!n) return '0 B';\n const u = ['B', 'KB', 'MB', 'GB', 'TB'];\n let i = 0;\n while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }\n return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;\n }\n\n function renderProgress(task) {\n progressEl.classList.remove('hidden');\n const total = task.bytes_total || 0;\n const done = task.bytes_done || 0;\n const pct = total > 0 ? Math.min(100, (done / total) * 100) : 0;\n progressFillEl.style.width = pct + '%';\n if (task.status === 'downloading') {\n progressLabelEl.textContent = t('system.emb.progress.downloading');\n progressPctEl.textContent = total > 0 ? `${pct.toFixed(1)}%` : '';\n progressDetailEl.textContent = total > 0\n ? `${fmtBytes(done)} / ${fmtBytes(total)}${task.current_file ? ' · ' + task.current_file : ''}`\n : (task.current_file || t('system.emb.progress.resolving'));\n } else if (task.status === 'verifying') {\n progressLabelEl.textContent = t('system.emb.progress.verifying');\n progressPctEl.textContent = '';\n progressFillEl.style.width = '100%';\n progressDetailEl.textContent = t('system.emb.progress.verifying_detail');\n } else if (task.status === 'done') {\n progressLabelEl.textContent = t('system.emb.progress.ready');\n progressPctEl.textContent = '';\n progressFillEl.style.background = 'var(--accent-green)';\n progressFillEl.style.width = '100%';\n progressDetailEl.textContent = `dimension=${task.dimension}`;\n } else if (task.status === 'failed') {\n progressLabelEl.textContent = t('system.emb.progress.failed');\n progressFillEl.style.background = 'var(--accent-red)';\n progressDetailEl.textContent = task.error || '';\n }\n }\n\n function resetProgress() {\n progressEl.classList.add('hidden');\n progressFillEl.style.background = 'var(--accent)';\n progressFillEl.style.width = '0';\n progressPctEl.textContent = '';\n progressDetailEl.textContent = '';\n }\n\n async function pollDownload(taskId) {\n while (true) {\n let task;\n try {\n task = await api.getTestEmbeddingStatus(taskId);\n } catch (e) {\n throw new Error(t('system.emb.poll_lost_connection') + e.message);\n }\n renderProgress(task);\n if (task.status === 'done' || task.status === 'failed') return task;\n await new Promise(r => setTimeout(r, 500));\n }\n }\n\n enabledToggle.addEventListener('click', () => {\n const cb = enabledToggle.querySelector('input');\n cb.checked = !cb.checked;\n enabledToggle.classList.toggle('on', cb.checked);\n fieldsDiv.style.opacity = cb.checked ? '' : '0.5';\n fieldsDiv.style.pointerEvents = cb.checked ? '' : 'none';\n });\n\n function setProvider(p) {\n currentProvider = p;\n providerSeg.querySelectorAll('.seg-btn').forEach(b => b.classList.toggle('active', b.dataset.provider === p));\n localPanel.style.display = p === 'local' ? '' : 'none';\n apiPanel.style.display = p === 'api' ? '' : 'none';\n }\n function setApiMode(m) {\n currentApiMode = m;\n apiModeSeg.querySelectorAll('.seg-btn').forEach(b => b.classList.toggle('active', b.dataset.mode === m));\n standardPanel.style.display = m === 'litellm' ? '' : 'none';\n customPanel.style.display = m === 'custom' ? '' : 'none';\n }\n providerSeg.querySelectorAll('.seg-btn').forEach(b => b.addEventListener('click', () => setProvider(b.dataset.provider)));\n apiModeSeg.querySelectorAll('.seg-btn').forEach(b => b.addEventListener('click', () => setApiMode(b.dataset.mode)));\n setProvider(currentProvider);\n setApiMode(currentApiMode);\n\n let localMatch = EMB_LOCAL_PRESETS.length - 1;\n for (let i = 0; i < EMB_LOCAL_PRESETS.length - 1; i++) {\n if (localModelInit === EMB_LOCAL_PRESETS[i].model) { localMatch = i; break; }\n }\n localPresetSelect.value = localMatch;\n localPresetSelect.addEventListener('change', () => {\n const p = EMB_LOCAL_PRESETS[localPresetSelect.value];\n if (p.model) localModelInput.value = p.model;\n });\n\n let apiMatch = EMB_API_PRESETS.length - 1;\n for (let i = 0; i < EMB_API_PRESETS.length - 1; i++) {\n if (apiModelInit === EMB_API_PRESETS[i].model) { apiMatch = i; break; }\n }\n apiPresetSelect.value = apiMatch;\n apiPresetSelect.addEventListener('change', () => {\n const p = EMB_API_PRESETS[apiPresetSelect.value];\n if (p.model) apiModelInput.value = p.model;\n if (p.url !== undefined) baseUrlInput.value = p.url;\n });\n\n let keyRevealed = false;\n keyEyeBtn.addEventListener('click', async () => {\n if (keyRevealed) {\n apiKeyInput.type = 'password';\n keyRevealed = false;\n keyEyeBtn.style.color = '';\n } else {\n try {\n const res = await api.revealConfigValue('embedding_api_key');\n apiKeyInput.type = 'text';\n apiKeyInput.value = res.value || '';\n apiKeyInput.placeholder = res.value ? '' : t('system.placeholder.not_set');\n keyRevealed = true;\n keyEyeBtn.style.color = 'var(--accent)';\n } catch (e) {\n error(t('system.toast.reveal_key_failed') + e.message);\n }\n }\n });\n\n httpHeadersEye.addEventListener('click', async () => {\n try {\n const res = await api.revealConfigValue('embedding_http_headers');\n if (res.value) httpHeadersInput.value = res.value;\n httpHeadersEye.style.color = 'var(--accent)';\n } catch (e) {\n error(t('system.toast.reveal_headers_failed') + e.message);\n }\n });\n\n async function runTest(params, logHeader) {\n resetProgress();\n statusEl.innerHTML = `<span style=\"color:var(--accent)\">${t('settings.testing')}</span>`;\n logEl.classList.remove('hidden');\n logEl.style.borderColor = 'var(--border)';\n logEl.textContent = logHeader;\n testBtn.disabled = true;\n try {\n const res = await api.testEmbedding(params);\n if (res.async && res.task_id) {\n renderProgress({ status: 'downloading', bytes_done: 0, bytes_total: 0, current_file: '' });\n logEl.textContent += `\\n[INFO] ${t('system.emb.log.download_started')} (task_id=${res.task_id}).\\n`;\n let final;\n try {\n final = await pollDownload(res.task_id);\n } catch (e) {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('system.emb.status.polling_failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${e.message}`;\n logEl.style.borderColor = 'var(--accent-red)';\n return;\n }\n if (final.status === 'done') {\n statusEl.innerHTML = `<span style=\"color:var(--accent-green)\">✓ ${t('system.emb.status.ok_dimension')}=${final.dimension}</span>`;\n logEl.textContent += `\\n[OK] ${t('system.emb.log.downloaded_verified')}, dimension=${final.dimension}`;\n logEl.style.borderColor = 'var(--accent-green)';\n } else {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('system.emb.status.download_failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${final.error || t('system.emb.log.unknown_error')}`;\n logEl.style.borderColor = 'var(--accent-red)';\n }\n } else if (res.success) {\n statusEl.innerHTML = `<span style=\"color:var(--accent-green)\">✓ ${t('system.emb.status.ok_dimension')}=${res.dimension}</span>`;\n logEl.textContent += `\\n[OK] ${res.message}`;\n logEl.style.borderColor = 'var(--accent-green)';\n } else {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('settings.failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${res.error}`;\n logEl.style.borderColor = 'var(--accent-red)';\n }\n } catch (e) {\n statusEl.innerHTML = `<span style=\"color:var(--accent-red)\">✗ ${t('system.emb.status.request_failed')}</span>`;\n logEl.textContent += `\\n[ERROR] ${e.message}`;\n logEl.style.borderColor = 'var(--accent-red)';\n } finally {\n testBtn.disabled = false;\n }\n }\n\n testBtn.addEventListener('click', async () => {\n if (currentProvider === 'local') {\n const model = localModelInput.value.trim();\n if (!model) { error(t('system.toast.model_required')); return; }\n await runTest({ provider: 'local', model }, `${t('system.emb.log.testing')}\\n${t('system.log.provider')}: local\\n${t('system.log.model')}: ${model}\\n`);\n } else if (currentApiMode === 'custom') {\n const http_url = httpUrlInput.value.trim();\n const http_body = httpBodyInput.value;\n if (!http_url) { error(t('system.toast.url_required_custom')); return; }\n if (!http_body.trim()) { error(t('system.toast.body_required_custom')); return; }\n await runTest(\n {\n provider: 'api',\n api_mode: 'custom',\n http_method: methodSelect.value,\n http_url,\n http_headers: httpHeadersInput.value,\n http_body,\n http_response_path: responsePathInput.value.trim() || 'data.*.embedding',\n },\n `${t('system.emb.log.testing')}\\n${t('system.log.provider')}: api (custom HTTP)\\n${methodSelect.value} ${http_url}\\n`\n );\n } else {\n const model = apiModelInput.value.trim();\n const base_url = baseUrlInput.value.trim() || null;\n const api_key = apiKeyInput.value.trim() || null;\n if (!model) { error(t('system.toast.model_required')); return; }\n if (!base_url) { error(t('system.toast.base_url_required_api')); return; }\n await runTest(\n { provider: 'api', api_mode: 'litellm', model, base_url, api_key },\n `${t('system.emb.log.testing')}\\n${t('system.log.provider')}: api (litellm)\\n${t('system.log.model')}: ${model}\\n${t('system.log.base_url')}: ${base_url}\\n`\n );\n }\n });\n\n async function refreshBadge() {\n const badge = section.querySelector('#emb-status-badge');\n try {\n const st = await api.getEmbeddingStatus();\n if (!st.enabled) {\n badge.className = 'tag tag-yellow';\n badge.textContent = t('system.badge.disabled');\n } else if (st.provider === 'api') {\n badge.className = 'tag tag-green';\n badge.textContent = st.api_mode === 'custom' ? t('system.badge.api_custom_http') : t('system.badge.api');\n } else if (st.cached) {\n badge.className = 'tag tag-green';\n badge.textContent = t('system.badge.local_cached');\n } else {\n badge.className = 'tag tag-yellow';\n badge.textContent = t('system.badge.local_not_downloaded');\n }\n } catch { /* ignore */ }\n }\n\n saveBtn.addEventListener('click', async () => {\n try {\n const enabled = enabledToggle.querySelector('input').checked;\n let needsRestart = false;\n const collect = (res) => { if (res && res.restart_required) needsRestart = true; };\n\n collect(await api.updateConfig('embedding_enabled', String(enabled)));\n collect(await api.updateConfig('embedding_provider', currentProvider));\n\n if (currentProvider === 'local') {\n const model = localModelInput.value.trim();\n if (model) collect(await api.updateConfig('embedding_model', model));\n collect(await api.updateConfig('hf_endpoint', hfEndpointInput.value.trim() || 'null'));\n } else {\n collect(await api.updateConfig('embedding_api_mode', currentApiMode));\n if (currentApiMode === 'custom') {\n collect(await api.updateConfig('embedding_http_method', methodSelect.value || 'POST'));\n collect(await api.updateConfig('embedding_http_url', httpUrlInput.value.trim() || 'null'));\n const headers = httpHeadersInput.value;\n if (headers && !headers.includes('****')) {\n collect(await api.updateConfig('embedding_http_headers', headers.trim() || 'null'));\n }\n collect(await api.updateConfig('embedding_http_body', httpBodyInput.value.trim() || 'null'));\n collect(await api.updateConfig('embedding_http_response_path', responsePathInput.value.trim() || 'data.*.embedding'));\n } else {\n const model = apiModelInput.value.trim();\n if (model) collect(await api.updateConfig('embedding_model', model));\n collect(await api.updateConfig('embedding_base_url', baseUrlInput.value.trim() || 'null'));\n const api_key = apiKeyInput.value.trim();\n if (api_key && !api_key.includes('****')) {\n collect(await api.updateConfig('embedding_api_key', api_key));\n }\n }\n }\n\n await refreshBadge();\n success(t('system.toast.emb_saved'));\n if (needsRestart) {\n offerRestart({ onRestarted: () => window.location.reload() });\n }\n } catch (e) {\n error(e.message);\n }\n });\n\n refreshBadge();\n\n return section;\n}\n"}}}} |