Compare commits

...
15 Commits
Author SHA1 Message Date
admin f7d441bd92 release: OpenNexus 0.5.0 2026-09-17 20:59:32 +08:00
admin e86809b238 feat(audio): 修复说话人聚类并更新至0.4.0-alpha.1 2026-09-16 22:05:09 +08:00
admin 81003d3106 build(release): 准备 OpenNexus 0.3.2-alpha.1 品牌安装包 [skip ci] 2026-09-16 00:40:59 +08:00
admin 9aed039702 feat: 发布 OpenNexus 0.3.1-alpha.3 [skip ci]
合并已完成验证的 Windows EXE 安装包和 Server Sync Docker 部署配置。
2026-09-15 11:11:28 +08:00
admin 586c4bf049 build(release): 准备0.3.1-alpha.3安装包与Docker部署 [skip ci] 2026-09-15 10:58:02 +08:00
Kronecker 3bbd7a2e1b feat: 发布 OpenNexus 0.3.1-alpha.2 [skip ci]
合并已完成审阅的工作区图片、Sync 首次凭据与0.3.1-alpha.2发行更新。
2026-09-15 02:21:38 +08:00
admin 179542cd26 docs(release): 将0.3.1-alpha.2标记为正式发布 [skip ci] 2026-09-15 02:19:24 +08:00
admin 64e992e172 chore(release): 准备合并0.3.1-alpha.2 [skip ci] 2026-09-15 02:18:12 +08:00
admin 0839f28e24 docs(sync): 更新0.3.1-alpha.2部署说明 2026-09-15 02:10:28 +08:00
admin a908319f1e docs: 更新0.3.1-alpha.2发行说明 2026-09-15 02:09:18 +08:00
admin a82af4fb70 build(release): 准备0.3.1-alpha.2预发布 2026-09-15 01:53:15 +08:00
admin d09a782fde fix(sync): 允许更新可选同步范围 2026-09-15 01:24:05 +08:00
admin 4b6f42c4d0 fix(runtime): 修复桌面端最小环境下的 CUDA 缓存初始化 2026-09-15 01:03:21 +08:00
admin 155f60cdd9 feat(sync): 支持升级实例创建临时管理员 2026-09-15 00:27:30 +08:00
admin e119811800 feat(sync): 首次登录后固定随机初始凭据 2026-09-15 00:23:20 +08:00
65 changed files with 1112 additions and 120 deletions
+5
View File
@@ -68,6 +68,7 @@ jobs:
bundle = @{ bundle = @{
active = $true active = $true
targets = @('nsis') targets = @('nsis')
icon = @('icons/icon.png', 'icons/icon.ico')
resources = @{ resources = @{
'../../.build/sidecar/dist/opennexus-core/' = 'core/' '../../.build/sidecar/dist/opennexus-core/' = 'core/'
} }
@@ -75,6 +76,10 @@ jobs:
certificateThumbprint = $env:OPENNEXUS_WINDOWS_CERTIFICATE_THUMBPRINT certificateThumbprint = $env:OPENNEXUS_WINDOWS_CERTIFICATE_THUMBPRINT
digestAlgorithm = 'sha256' digestAlgorithm = 'sha256'
timestampUrl = 'http://timestamp.digicert.com' timestampUrl = 'http://timestamp.digicert.com'
nsis = @{
installerIcon = 'icons/icon.ico'
uninstallerIcon = 'icons/icon.ico'
}
} }
} }
} | ConvertTo-Json -Depth 5 } | ConvertTo-Json -Depth 5
+27 -6
View File
@@ -2,7 +2,7 @@
OpenNexus 是一款本地优先的 AI 笔记与知识中枢。它将 Markdown Vault、全文与向量检索、知识库问答、可审计 Agent、扩展系统和多设备同步整合在一个桌面应用中。笔记与索引由用户掌控;需要模型或同步服务时,再按需连接本地或远程服务。 OpenNexus 是一款本地优先的 AI 笔记与知识中枢。它将 Markdown Vault、全文与向量检索、知识库问答、可审计 Agent、扩展系统和多设备同步整合在一个桌面应用中。笔记与索引由用户掌控;需要模型或同步服务时,再按需连接本地或远程服务。
当前发布版本为 **0.3.1-alpha.1**,主要支持 Windows x64。Alpha 版本用于验证完整业务闭环和部署方案,升级前请备份 Vault。 当前发布版本为 **0.5.0**,主要支持 Windows x64。升级前请备份 Vault。
## 主要能力 ## 主要能力
@@ -34,9 +34,13 @@ flowchart LR
## 使用发布包 ## 使用发布包
1. 下载 Windows x64 软件包,并核对发布页中的 SHA-256。 本版提供 Windows x64 EXE 安装包和独立的 Server Sync 包,下载入口见 [v0.5.0 发布页](https://gitea.kronecker.cc/Kronecker/NotesAgentic/releases/tag/v0.5.0)。发布页同时附带 `SHA256.json`,用于核对文件完整性
2. 将便携版完整解压到可写目录,不要单独移动可执行文件。
3. 启动 `OpenNexus.exe`,选择已有 Vault 或创建新 Vault。 安装包不包含任何 Vault 或用户数据,也不预装已下载的社区主题、本地模型权重、CUDA 与 PyTorch 运行时。相关功能仍完整保留;需要时可在客户端内按需安装主题、选择模型或配置 CUDA 环境。程序自带的基础界面样式属于客户端资源,不视为社区主题。同一 Windows 用户下升级安装会继续使用 `%APPDATA%\cc.kronecker.notesagent` 中的既有配置和索引,以及用户此前选择的外部 Vault。
1. 下载 Windows x64 EXE 安装包,并核对发布页中的 SHA-256。
2. 运行安装程序,按向导完成当前用户安装;未签名的 Alpha 包可能触发 Windows 未知发布者提示。
3. 从开始菜单启动 OpenNexus,选择已有 Vault 或创建新 Vault。
4. 在“设置 → 模型提供商”中配置本地模型或远程模型凭据。 4. 在“设置 → 模型提供商”中配置本地模型或远程模型凭据。
5. 如需多设备同步,在同步设置中填写管理员提供的 Sync Server 地址并登录。 5. 如需多设备同步,在同步设置中填写管理员提供的 Sync Server 地址并登录。
@@ -120,7 +124,22 @@ Gitea Actions 会在推送和合并请求时执行文档检查、后端测试、
## 部署 Sync Server ## 部署 Sync Server
开发或内网验证可直接运行 推荐使用 Docker Compose 启动 PostgreSQL、MinIO 和 Sync
```powershell
cd "server sync"
Copy-Item .env.example .env
# 编辑 .env 并生成各项独立密钥
docker compose up -d --build
```
隔离测试阶段如需直接开放 `18080` 明文端口,可使用 Docker Compose 2.24.4 或更高版本加载测试覆盖文件:
```powershell
docker compose -f compose.yaml -f compose.test.yaml up -d --build
```
不使用容器的开发联调也可直接运行:
```powershell ```powershell
cd "server sync" cd "server sync"
@@ -130,6 +149,8 @@ uv run uvicorn sync_server.main:app --host 0.0.0.0 --port 18080
管理控制台构建后由 Sync Server 一并提供。正式环境应使用 PostgreSQL、S3 兼容对象存储、独立密钥、TLS 终止、进程守护和定期备份;完整变量与部署方式见 [`server sync/README.md`](server%20sync/README.md)。 管理控制台构建后由 Sync Server 一并提供。正式环境应使用 PostgreSQL、S3 兼容对象存储、独立密钥、TLS 终止、进程守护和定期备份;完整变量与部署方式见 [`server sync/README.md`](server%20sync/README.md)。
新建 Sync 实例首次启动时会生成仅对本次启动有效的随机管理员密码。管理员首次登录后必须修改账户和密码;修改成功后凭据写入数据库,后续重启不再随机更换。升级已有实例会保留已固定的凭据、Vault、设备和修订记录。
## 仓库结构 ## 仓库结构
```text ```text
@@ -156,7 +177,7 @@ OpenNexus/
OpenNexus 将 Vault 内容、模型凭据和扩展权限视为敏感数据。请只安装可信来源的 Skill、Plugin 与主题包,并在授权前检查其权限。服务端部署不得使用示例密钥或开发数据库。 OpenNexus 将 Vault 内容、模型凭据和扩展权限视为敏感数据。请只安装可信来源的 Skill、Plugin 与主题包,并在授权前检查其权限。服务端部署不得使用示例密钥或开发数据库。
正式发行物通过 Git 标签追踪,并在发布页提供校验和。Windows 安装包的生产门禁还会验证 Authenticode 和 Core 清单签名。无法通过签名门禁的构建只能作为预发布测试包分发 正式发行物通过 Git 标签追踪,并在发布页提供校验和。Windows 安装包的生产门禁还会验证 Authenticode 和 Core 清单签名。本版 EXE 安装包尚未进行 Authenticode 签名,Windows 可能显示未知发布者提示
## 参与开发 ## 参与开发
+1 -1
View File
@@ -33,7 +33,7 @@ def get_settings() -> Settings:
data_dir = Path(os.getenv("APP_DATA_DIR", str(BACKEND_DIR / "data"))) data_dir = Path(os.getenv("APP_DATA_DIR", str(BACKEND_DIR / "data")))
return Settings( return Settings(
name=os.getenv("APP_NAME", "OpenNexus AI Core"), name=os.getenv("APP_NAME", "OpenNexus AI Core"),
version=os.getenv("APP_VERSION", "0.1.0"), version=os.getenv("APP_VERSION", "0.5.0"),
environment=os.getenv("APP_ENVIRONMENT", "development"), environment=os.getenv("APP_ENVIRONMENT", "development"),
host=os.getenv("APP_HOST", "127.0.0.1"), host=os.getenv("APP_HOST", "127.0.0.1"),
port=int(os.getenv("APP_PORT", "8000")), port=int(os.getenv("APP_PORT", "8000")),
+6
View File
@@ -1250,6 +1250,12 @@ class TranscriptNoteRequest(Contract):
include_speakers: bool = True include_speakers: bool = True
class TranscriptArtifactsRequest(TranscriptNoteRequest):
provider_id: str = Field(min_length=1, max_length=128)
model: str = Field(min_length=1, max_length=256)
knowledge_title: str | None = Field(default=None, min_length=1, max_length=200)
class IndexStatus(Contract): class IndexStatus(Contract):
running_jobs: int = 0 running_jobs: int = 0
active_searches: int = 0 active_searches: int = 0
+5 -2
View File
@@ -5,11 +5,14 @@ import os
import shutil import shutil
import subprocess import subprocess
from app.config import BACKEND_DIR from app.config import BACKEND_DIR, get_settings
from app.errors import ApiError from app.errors import ApiError
from app.local_models.process import ThreadedProcess from app.local_models.process import ThreadedProcess
ROOT = BACKEND_DIR / '.venv-models-cuda' # 模型运行环境会在安装与升级时写入大量文件,必须位于应用数据目录,
# 不能写入受完整性清单保护的 Core 发布目录。
DEFAULT_ROOT = get_settings().data_dir / 'model-runtime'
ROOT = DEFAULT_ROOT
state = {'status': 'unchecked', 'stage': '', 'cuda_available': None} state = {'status': 'unchecked', 'stage': '', 'cuda_available': None}
task = None task = None
+107 -10
View File
@@ -8,6 +8,23 @@ import sys
import threading import threading
import time import time
# Worker 在发布包的临时挂载目录中运行,不能留下会触发 Core 完整性校验的字节码。
sys.dont_write_bytecode = True
# 桌面 Host 只向 Core 传入最小环境。PyTorch 编译缓存会通过 getpass
# 读取用户名;在 Windows 上缺少 USERNAME 时,它会误尝试导入 Unix 的 pwd。
os.environ.setdefault(
"USERNAME", os.path.basename(os.environ.get("USERPROFILE", "OpenNexus"))
)
os.environ.setdefault(
"TORCHINDUCTOR_CACHE_DIR",
os.path.join(
os.environ.get("LOCALAPPDATA", os.environ.get("TEMP", ".")),
"OpenNexus",
"torchinductor",
),
)
def decode(path, *, limit_seconds=3600, warnings=None): def decode(path, *, limit_seconds=3600, warnings=None):
import av import av
@@ -98,6 +115,91 @@ def voice_embedding(model, audio, device):
return torch.nn.functional.normalize(vector, dim=0) return torch.nn.functional.normalize(vector, dim=0)
def _normalized_vector(values):
"""把声纹向量转成普通列表并归一化,便于在无 PyTorch 的 API 测试环境中验证聚类。"""
import math
values = [float(value) for value in values]
norm = math.sqrt(sum(value * value for value in values))
if not values or not math.isfinite(norm) or norm <= 1e-12:
raise ValueError("Invalid speaker embedding")
return [value / norm for value in values]
def _similarity(left, right):
return sum(a * b for a, b in zip(left, right, strict=True))
def cluster_speaker_embeddings(embeddings, segments, *, threshold=0.36):
"""聚类片段声纹,并把过短片段交给相邻的稳定说话人。
质心在每次接收新样本后更新避免第一段永久决定整簇持续时间不超过
3 秒的孤立单例通常是停顿处的语气词将它并入最相近的已有稳定簇
同时保留由多个片段支持的第三位及更多说话人
"""
if len(embeddings) != len(segments):
raise ValueError("Speaker embeddings and segments must have the same length")
vectors = [None if value is None else _normalized_vector(value) for value in embeddings]
assignments = [None] * len(vectors)
clusters = []
for index, vector in enumerate(vectors):
if vector is None:
continue
similarities = [_similarity(vector, cluster["centroid"]) for cluster in clusters]
best = max(range(len(similarities)), key=similarities.__getitem__) if similarities else None
if best is None or similarities[best] < threshold:
best = len(clusters)
clusters.append({"members": [], "sum": [0.0] * len(vector), "centroid": vector})
cluster = clusters[best]
cluster["members"].append(index)
cluster["sum"] = [total + value for total, value in zip(cluster["sum"], vector, strict=True)]
cluster["centroid"] = _normalized_vector(cluster["sum"])
assignments[index] = best
# 短语气词可能形成只有一个片段的离群簇。仅合并短单例,不吞掉由多个
# 片段支持的真实少数说话人。
stable = [index for index, cluster in enumerate(clusters) if len(cluster["members"]) > 1]
for index, cluster in enumerate(clusters):
member = cluster["members"][0] if len(cluster["members"]) == 1 else None
if member is None or not stable:
continue
duration = float(segments[member]["end_time"]) - float(segments[member]["start_time"])
if duration > 3.0:
continue
target = max(stable, key=lambda other: _similarity(cluster["centroid"], clusters[other]["centroid"]))
assignments[member] = target
# 没有足够语音生成声纹的短片段继承时间上最近的稳定标签。同一说话人
# 两个片段之间的语气词会优先落回该说话人。
labeled = [index for index, value in enumerate(assignments) if value is not None]
for index, value in enumerate(assignments):
if value is not None or not labeled:
continue
previous = next((item for item in reversed(labeled) if item < index), None)
following = next((item for item in labeled if item > index), None)
if previous is not None and following is not None and assignments[previous] == assignments[following]:
assignments[index] = assignments[previous]
continue
candidates = []
if previous is not None:
distance = max(0.0, float(segments[index]["start_time"]) - float(segments[previous]["end_time"]))
candidates.append((distance, 0, assignments[previous]))
if following is not None:
distance = max(0.0, float(segments[following]["start_time"]) - float(segments[index]["end_time"]))
candidates.append((distance, 1, assignments[following]))
assignments[index] = min(candidates)[2] if candidates else None
# 合并后按首次出现顺序重新编号,避免 speaker_1、speaker_3 这样的空洞 ID。
remap = {}
speakers = []
for value in assignments:
if value is None:
speakers.append(None)
continue
remap.setdefault(value, len(remap) + 1)
speakers.append(f"speaker_{remap[value]}")
return speakers
class CudaInitializationError(RuntimeError): class CudaInitializationError(RuntimeError):
pass pass
@@ -172,20 +274,15 @@ def run(request):
model = speaker_model(path, device) model = speaker_model(path, device)
loaded = time.monotonic() loaded = time.monotonic()
audio = decode(payload["source"]) audio = decode(payload["source"])
centroids, speakers = [], [] embeddings = []
for segment in payload["segments"]: for segment in payload["segments"]:
sample = audio[int(segment["start_time"] * 16000):int(segment["end_time"] * 16000)] sample = audio[int(segment["start_time"] * 16000):int(segment["end_time"] * 16000)]
if len(sample) < 16000: if len(sample) < 16000:
speakers.append(None) embeddings.append(None)
continue continue
vector = voice_embedding(model, sample, device) embeddings.append(voice_embedding(model, sample, device).tolist())
similarities = [float(torch.dot(vector, c)) for c in centroids] speakers = cluster_speaker_embeddings(embeddings, payload["segments"])
best = max(range(len(similarities)), key=similarities.__getitem__) if similarities else None result = {"speakers": speakers, "unassigned_segments": sum(speaker is None for speaker in speakers)}
if best is None or similarities[best] < 0.36:
best = len(centroids)
centroids.append(vector)
speakers.append(f"speaker_{best + 1}")
result = {"speakers": speakers}
else: else:
raise ValueError("Unknown inference operation") raise ValueError("Unknown inference operation")
return {"result": result, "usage": usage, "audio_seconds": audio_seconds, "diagnostics": {"requested_device": requested, "actual_device": device, return {"result": result, "usage": usage, "audio_seconds": audio_seconds, "diagnostics": {"requested_device": requested, "actual_device": device,
+7 -1
View File
@@ -11,7 +11,7 @@ from uuid import uuid4
from fastapi import APIRouter, Header, Query, Request from fastapi import APIRouter, Header, Query, Request
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from app.contracts import TranscriptEditRequest, TranscriptNoteRequest, TranscriptionJob from app.contracts import TranscriptArtifactsRequest, TranscriptEditRequest, TranscriptNoteRequest, TranscriptionJob
from app.database.db import connect, transaction from app.database.db import connect, transaction
from app.errors import ApiError from app.errors import ApiError
from app.services import transcription_service as jobs from app.services import transcription_service as jobs
@@ -160,6 +160,12 @@ async def create_note(job_id: str, request: TranscriptNoteRequest):
return await create_transcript_note(job_id, request) return await create_transcript_note(job_id, request)
@router.post("/transcriptions/{job_id}/artifacts", status_code=201)
async def create_artifacts(job_id: str, request: TranscriptArtifactsRequest):
from app.services.media_notes import create_transcript_artifacts
return await create_transcript_artifacts(job_id, request)
@router.get("/attachments/{attachment_id}/cleanup-impact") @router.get("/attachments/{attachment_id}/cleanup-impact")
async def cleanup_impact(attachment_id: str): async def cleanup_impact(attachment_id: str):
attachment_path(attachment_id) attachment_path(attachment_id)
+1 -1
View File
@@ -41,7 +41,7 @@ async def execute(call, request):
run = await container.agent.create_run(AgentRunCreateRequest( run = await container.agent.create_run(AgentRunCreateRequest(
input=task, provider_id=request.provider_id, model=request.model, input=task, provider_id=request.provider_id, model=request.model,
skill_id=skill_id, skill_id=skill_id,
allowed_tools=ALLOWED_TOOLS, max_steps=10, token_budget=16000, allowed_tools=ALLOWED_TOOLS, max_steps=10, token_budget=None,
allow_network=False, metadata={'source': 'chat', 'conversation_id': request.conversation_id}, allow_network=False, metadata={'source': 'chat', 'conversation_id': request.conversation_id},
)) ))
elif call.name == 'agent.status': elif call.name == 'agent.status':
+133
View File
@@ -4,8 +4,11 @@ import hashlib
from contextlib import closing from contextlib import closing
from app.config import get_settings from app.config import get_settings
from app.contracts import Message, MessageRole, ModelRequest, TranscriptNoteRequest
from app.database.db import connect, transaction from app.database.db import connect, transaction
from app.errors import ApiError from app.errors import ApiError
from app.providers.base import ProviderError
from app.providers.registry import ProviderNotFoundError
from app.services import note_service from app.services import note_service
from app.services.transcription_service import require_job from app.services.transcription_service import require_job
@@ -66,6 +69,136 @@ async def create_transcript_note(job_id, options):
return note return note
def _transcript_text(job) -> str:
if job.segments:
rows = []
for segment in job.segments:
speaker = job.speaker_names.get(segment.speaker, segment.speaker) if segment.speaker else ""
stamp = f"{int(segment.start_time // 60):02}:{int(segment.start_time % 60):02}"
rows.append(f"[{stamp}] {speaker}{segment.text}" if speaker else f"[{stamp}] {segment.text}")
return "\n".join(rows)
return job.text or ""
def _chunks(text: str, limit: int = 12000) -> list[str]:
"""按段落切分长转录,避免在中间截断句子。"""
paragraphs = [part.strip() for part in text.splitlines() if part.strip()]
if not paragraphs:
return []
chunks: list[str] = []
current: list[str] = []
size = 0
for paragraph in paragraphs:
if current and size + len(paragraph) + 1 > limit:
chunks.append("\n".join(current))
current, size = [], 0
if len(paragraph) > limit:
if current:
chunks.append("\n".join(current))
current, size = [], 0
chunks.extend(paragraph[index:index + limit] for index in range(0, len(paragraph), limit))
continue
current.append(paragraph)
size += len(paragraph) + 1
if current:
chunks.append("\n".join(current))
return chunks
async def _complete(provider_id: str, model: str, system: str, content: str) -> str:
from app.container import container
try:
provider = container.providers.get(provider_id).adapter
except ProviderNotFoundError as exc:
raise ApiError(404, "PROVIDER_NOT_FOUND", "所选模型提供商不存在或未启用。",
{"provider_id": provider_id}) from exc
try:
turn = await provider.complete(ModelRequest(
provider_id=provider_id,
model=model,
system=system,
messages=[Message(role=MessageRole.user, content=content)],
temperature=0.2,
))
except ProviderError as exc:
raise ApiError(502, exc.code, exc.message, {"provider_id": provider_id}) from exc
if not turn.text or not turn.text.strip():
raise ApiError(502, "KNOWLEDGE_NOTE_EMPTY", "模型没有返回知识点笔记。")
return turn.text.strip().removeprefix("```markdown").removeprefix("```").removesuffix("```").strip()
async def _knowledge_markdown(job, provider_id: str, model: str, title: str) -> str:
transcript = _transcript_text(job)
if not transcript.strip():
raise ApiError(409, "TRANSCRIPT_EMPTY", "转录内容为空,无法提取知识点。")
system = (
"你是一名严谨的课程笔记整理助手。只能依据提供的转录内容整理,不补写未出现的事实。"
"输出中文 Markdown 正文,使用清晰的二级、三级标题;包含课程主题、核心概念、关键论证或步骤、"
"重要例子、待复习问题。合并口语重复,保留专业术语和必要条件。不要使用代码围栏,也不要写处理说明。"
)
parts = _chunks(transcript)
summaries: list[str] = []
for index, part in enumerate(parts, 1):
summaries.append(await _complete(
provider_id, model, system,
f"这是课程转录的第 {index}/{len(parts)} 部分。请提取可供最终整合的知识点:\n\n{part}",
))
if len(summaries) == 1:
body = summaries[0]
else:
body = await _complete(
provider_id, model, system,
"请将以下分段知识点合并成一篇完整课程笔记,消除重复并保持逻辑顺序:\n\n"
+ "\n\n".join(f"### 分段 {index}\n{summary}" for index, summary in enumerate(summaries, 1)),
)
return "\n".join([
f"<!-- knowledge-note:{job.job_id}:{job.revision}:{provider_id}:{model} -->",
f"# {title}", "", f"[查看完整转录稿](/#/media?job={job.job_id})", "", body,
])
async def create_transcript_artifacts(job_id, options):
"""为完成的转录生成可回听的全文和模型整理的知识点笔记。"""
transcript_options = TranscriptNoteRequest(
title=options.title,
folder=options.folder,
update_existing=options.update_existing,
include_timestamps=options.include_timestamps,
include_speakers=options.include_speakers,
)
transcript_note = await create_transcript_note(job_id, transcript_options)
job = require_job(job_id)
knowledge_title = options.knowledge_title or f"{options.title} · 知识点"
identity = (str(get_settings().db_path), job_id, "knowledge")
lock = _locks.setdefault(identity, asyncio.Lock())
async with lock:
signature = "knowledge:" + hashlib.sha256(options.model_copy(update={
"update_existing": False,
"knowledge_title": knowledge_title,
}).model_dump_json(exclude={"update_existing"}).encode()).hexdigest()
with closing(connect()) as conn:
row = conn.execute(
"SELECT note_id FROM media_notes WHERE job_id=? AND revision=? AND options_hash=?",
(job_id, job.revision, signature),
).fetchone()
if row:
knowledge_note = await note_service.get_note(row[0])
if knowledge_note is not None:
return {"transcript": transcript_note, "knowledge_note": knowledge_note}
markdown = await _knowledge_markdown(job, options.provider_id, options.model, knowledge_title)
note_title = f"{knowledge_title} · {job_id[-8:]}-r{job.revision}-{signature[-6:]}"
knowledge_note = await note_service.create_note(
title=note_title,
markdown=markdown,
folder=options.folder,
tags=["课程笔记", "知识点"],
)
with closing(connect()) as conn, transaction(conn):
conn.execute("INSERT OR IGNORE INTO media_notes VALUES (?,?,?,?)",
(job_id, job.revision, signature, knowledge_note.note_id))
return {"transcript": transcript_note, "knowledge_note": knowledge_note}
async def _create_note(title, markdown, options, marker): async def _create_note(title, markdown, options, marker):
try: try:
note = await note_service.create_note(title=title, markdown=markdown, folder=options.folder, tags=["转写"]) note = await note_service.create_note(title=title, markdown=markdown, folder=options.folder, tags=["转写"])
@@ -174,6 +174,8 @@ async def _execute(job_id, request, routing=None):
for segment, speaker in zip(job.segments, result["speakers"], strict=True): for segment, speaker in zip(job.segments, result["speakers"], strict=True):
segment.speaker = speaker segment.speaker = speaker
job.warnings.append("DIARIZATION_SEGMENT_LEVEL") job.warnings.append("DIARIZATION_SEGMENT_LEVEL")
if result.get("unassigned_segments"):
job.warnings.append("DIARIZATION_PARTIAL")
except ProviderError: except ProviderError:
job.warnings.append("DIARIZATION_UNAVAILABLE") job.warnings.append("DIARIZATION_UNAVAILABLE")
else: else:
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "notes-agent-backend" name = "notes-agent-backend"
version = "0.1.0" version = "0.5.0"
description = "Notes Agent 的 FastAPI 基础壳子" description = "Notes Agent 的 FastAPI 基础壳子"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+12
View File
@@ -96,6 +96,18 @@ def test_terminology_export_and_privacy_cleanup():
first = client.post(f'/api/media/transcriptions/{job_id}/notes', json={'title':'课程'}).json() first = client.post(f'/api/media/transcriptions/{job_id}/notes', json={'title':'课程'}).json()
again = client.post(f'/api/media/transcriptions/{job_id}/notes', json={'title':'课程'}).json() again = client.post(f'/api/media/transcriptions/{job_id}/notes', json={'title':'课程'}).json()
assert first['note_id'] == again['note_id'] assert first['note_id'] == again['note_id']
artifacts = client.post(f'/api/media/transcriptions/{job_id}/artifacts', json={
'title': '课程', 'knowledge_title': '课程知识点',
'provider_id': 'mock', 'model': 'mock-1',
})
assert artifacts.status_code == 201
assert artifacts.json()['transcript']['note_id'] == first['note_id']
assert artifacts.json()['knowledge_note']['note_id'] != first['note_id']
repeated = client.post(f'/api/media/transcriptions/{job_id}/artifacts', json={
'title': '课程', 'knowledge_title': '课程知识点',
'provider_id': 'mock', 'model': 'mock-1',
})
assert repeated.json()['knowledge_note']['note_id'] == artifacts.json()['knowledge_note']['note_id']
response = client.delete('/api/media/attachments/lecture.txt') response = client.delete('/api/media/attachments/lecture.txt')
assert first['note_id'] in response.json()['retained_note_ids'] assert first['note_id'] in response.json()['retained_note_ids']
cleaned = client.get(f'/api/media/transcriptions/{job_id}').json() cleaned = client.get(f'/api/media/transcriptions/{job_id}').json()
-2
View File
@@ -13,8 +13,6 @@ def isolate(monkeypatch, tmp_path):
monkeypatch.setattr(components, 'ROOT', tmp_path / 'cuda') monkeypatch.setattr(components, 'ROOT', tmp_path / 'cuda')
monkeypatch.setattr(components, 'state', {'status': 'unchecked', 'stage': '', 'cuda_available': None}) monkeypatch.setattr(components, 'state', {'status': 'unchecked', 'stage': '', 'cuda_available': None})
monkeypatch.setattr(components, 'task', None) monkeypatch.setattr(components, 'task', None)
def test_status_checks_without_installing_and_detects_existing_cuda(monkeypatch): def test_status_checks_without_installing_and_detects_existing_cuda(monkeypatch):
python = components.ROOT / 'Scripts/python.exe' python = components.ROOT / 'Scripts/python.exe'
python.parent.mkdir(parents=True) python.parent.mkdir(parents=True)
+38
View File
@@ -0,0 +1,38 @@
from app.local_models.worker import cluster_speaker_embeddings
def segment(start, end):
return {"start_time": start, "end_time": end}
def test_centroid_updates_allow_one_speaker_to_drift():
speakers = cluster_speaker_embeddings(
[[1, 0], [0.8, 0.6], [0.55, 0.835]],
[segment(0, 2), segment(2, 4), segment(4, 6)],
threshold=0.7,
)
assert speakers == ["speaker_1"] * 3
def test_short_segments_and_short_singleton_join_stable_neighbors():
speakers = cluster_speaker_embeddings(
[[1, 0], None, [0.98, 0.1], [0, 1], [-0.9, -0.1], [0.1, 0.99]],
[segment(0, 2), segment(2, 2.4), segment(2.4, 5), segment(5, 8), segment(8, 9.5), segment(9.5, 12)],
)
assert speakers[0] == speakers[1] == speakers[2] == "speaker_1"
assert speakers[3] == speakers[4] == speakers[5] == "speaker_2"
assert None not in speakers
def test_multiple_supported_speakers_are_not_collapsed():
speakers = cluster_speaker_embeddings(
[[1, 0, 0], [0.99, 0.05, 0], [0, 1, 0], [0.05, 0.99, 0], [0, 0, 1], [0, 0.05, 0.99]],
[segment(i * 2, i * 2 + 2) for i in range(6)],
)
assert speakers == ["speaker_1", "speaker_1", "speaker_2", "speaker_2", "speaker_3", "speaker_3"]
def test_all_too_short_remains_unassigned_without_model_evidence():
assert cluster_speaker_embeddings(
[None, None], [segment(0, 0.4), segment(0.5, 0.9)]
) == [None, None]
+1 -1
View File
@@ -1108,7 +1108,7 @@ wheels = [
[[package]] [[package]]
name = "notes-agent-backend" name = "notes-agent-backend"
version = "0.1.0" version = "0.5.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
@@ -53,7 +53,7 @@ API 保留 `backend/.venv`,模型依赖安装到独立的 `backend/.venv-model
API 优先,无配置或无效结果时本地回退。local_only 禁止远程模型。纯文本附件和既有 sidecar 可导入,但已有真实音频时不使用旁边文本冒充识别。 API 优先,无配置或无效结果时本地回退。local_only 禁止远程模型。纯文本附件和既有 sidecar 可导入,但已有真实音频时不使用旁边文本冒充识别。
PyAV 提取音轨至 16 kHz 单声道,最长 1 小时,禁止解码器网络协议。能量分段后交给 Qwen3-ASR,返回片段边界,不宣称逐字对齐。ERes2NetV2 提取片段声纹并按相似度聚类;短片段、同段多人重叠发言需要人工校对。缺失能力返回 DIARIZATION_UNAVAILABLE;未启用逐字对齐返回 WORD_TIMESTAMPS_UNAVAILABLE。 PyAV 提取音轨至 16 kHz 单声道,最长 1 小时,禁止解码器网络协议。能量分段后交给 Qwen3-ASR,返回片段边界,不宣称逐字对齐。ERes2NetV2 提取片段声纹并按更新后的簇质心聚类;不足 1 秒的片段继承时间上最近的稳定说话人,3 秒以内且没有其他片段支持的离群簇并入最相近的稳定簇。由多个片段支持的少数说话人仍会保留。同段多人重叠发言需要人工校对;仍无法分配的片段返回 DIARIZATION_PARTIAL。缺失能力返回 DIARIZATION_UNAVAILABLE;未启用逐字对齐返回 WORD_TIMESTAMPS_UNAVAILABLE。
术语是识别后的替换规则,保留原始文本和来源。重命名只修改显示名,稳定 ID 不变。笔记包含音频与时间跳转链接;重复导出不覆盖用户编辑。清理保留已导出笔记,音频链接失效,已清理任务不可重试。重建索引保留转写与笔记关联。 术语是识别后的替换规则,保留原始文本和来源。重命名只修改显示名,稳定 ID 不变。笔记包含音频与时间跳转链接;重复导出不覆盖用户编辑。清理保留已导出笔记,音频链接失效,已清理任务不可重试。重建索引保留转写与笔记关联。
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#171717" /> <meta name="theme-color" content="#171717" />
<link rel="icon" type="image/svg+xml" href="/opennexus-logo.svg" />
<title>OpenNexus</title> <title>OpenNexus</title>
</head> </head>
<body> <body>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "notes-agent-frontend", "name": "notes-agent-frontend",
"private": true, "private": true,
"version": "0.3.1-alpha.1", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"desktop:build": "tauri build --no-bundle --features desktop", "desktop:build": "tauri build --features desktop --bundles nsis --config src-tauri/tauri.bundle.conf.json",
"test": "vitest run", "test": "vitest run",
"preview": "vite preview", "preview": "vite preview",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json && vue-tsc --noEmit -p tsconfig.node.json", "type-check": "vue-tsc --noEmit -p tsconfig.app.json && vue-tsc --noEmit -p tsconfig.node.json",
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-labelledby="title desc">
<title id="title">OpenNexus</title>
<desc id="desc">蓝紫色线框文档标志</desc>
<rect x="12" y="12" width="1000" height="1000" rx="252" fill="#fbfcff" stroke="#e1e5f0" stroke-width="24"/>
<path d="M254 184h322l194 194v462H254z" fill="none" stroke="#6269f6" stroke-width="48" stroke-linejoin="round"/>
<path d="M576 184v194h194" fill="none" stroke="#6269f6" stroke-width="48" stroke-linejoin="round"/>
<path d="M382 508h260M382 622h260M382 736h260" fill="none" stroke="#6269f6" stroke-width="42" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 651 B

+1 -1
View File
@@ -3242,7 +3242,7 @@ dependencies = [
[[package]] [[package]]
name = "notesagent-desktop" name = "notesagent-desktop"
version = "0.3.1-alpha.1" version = "0.5.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"base64 0.22.1", "base64 0.22.1",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "notesagent-desktop" name = "notesagent-desktop"
version = "0.3.1-alpha.1" version = "0.5.0"
edition = "2021" edition = "2021"
rust-version = "1.89" rust-version = "1.89"
@@ -48,7 +48,7 @@ cap-fs-ext = "4.0.2"
jsonschema = { version = "0.55", default-features = false } jsonschema = { version = "0.55", default-features = false }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Pipes", "Win32_System_IO", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] } windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Cryptography", "Win32_Security_Isolation", "Win32_Security_Authorization", "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_System_Pipes", "Win32_System_IO", "Win32_System_SystemInformation", "Win32_Storage_FileSystem", "Win32_System_RemoteDesktop", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader"] }
[build-dependencies] [build-dependencies]
tauri-build = { version = "2", optional = true , features = [] } tauri-build = { version = "2", optional = true , features = [] }
+1
View File
@@ -35,6 +35,7 @@ fn main() {
"sync_unbind", "sync_unbind",
"sync_pause", "sync_pause",
"sync_status", "sync_status",
"sync_set_scope",
"sync_resolve", "sync_resolve",
"sync_logout", "sync_logout",
"sync_run", "sync_run",
@@ -0,0 +1,44 @@
use notesagent_host::credentials::CredentialBroker;
use std::path::PathBuf;
fn main() -> Result<(), String> {
let mut arguments = std::env::args_os().skip(1);
let vault = PathBuf::from(arguments.next().ok_or("TARGET_REQUIRED")?);
let legacy = PathBuf::from(arguments.next().ok_or("LEGACY_REQUIRED")?);
if arguments.next().is_some() {
return Err("ARGUMENTS_INVALID".into());
}
let parent = vault.parent().ok_or("TARGET_INVALID")?;
let backup = parent.join("stronghold.pre-0.5.0.onxcred");
let auto_key = parent.join("auto-unlock.dpapi");
if auto_key.exists() {
return Err("AUTO_UNLOCK_ALREADY_CONFIGURED".into());
}
if backup.exists() {
return Err("BACKUP_ALREADY_EXISTS".into());
}
if vault.exists() {
std::fs::rename(&vault, &backup).map_err(|_| "BACKUP_FAILED")?;
}
let migrated = (|| {
let mut broker = CredentialBroker::new(vault.clone());
if !broker.ensure_system_unlock()? {
return Err("AUTO_UNLOCK_INITIALIZATION_FAILED".into());
}
broker.import_fernet(&legacy, None)
})();
match migrated {
Ok(count) => {
println!("Migrated {count} credential(s) to Windows automatic unlock.");
Ok(())
}
Err(error) => {
let _ = std::fs::remove_file(&vault);
let _ = std::fs::remove_file(&auto_key);
if backup.exists() {
let _ = std::fs::rename(&backup, &vault);
}
Err(error)
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-sync-set-scope"
description = "Enables the sync_set_scope command without any pre-configured scope."
commands.allow = ["sync_set_scope"]
[[permission]]
identifier = "deny-sync-set-scope"
description = "Denies the sync_set_scope command without any pre-configured scope."
commands.deny = ["sync_set_scope"]
@@ -0,0 +1,109 @@
//! Windows DPAPI-backed storage for the random Stronghold unlock secret.
use std::fs;
use std::io::Write;
use std::path::Path;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Cryptography::{
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
};
use zeroize::Zeroizing;
type Result<T> = std::result::Result<T, String>;
const MAGIC: &[u8] = b"ONXDPAPI1";
const ENTROPY: &[u8] = b"OpenNexus credential auto-unlock v1";
fn transform(data: &[u8], protect: bool) -> Result<Zeroizing<Vec<u8>>> {
let input = CRYPT_INTEGER_BLOB {
cbData: u32::try_from(data.len()).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?,
pbData: data.as_ptr() as *mut u8,
};
let entropy = CRYPT_INTEGER_BLOB {
cbData: ENTROPY.len() as u32,
pbData: ENTROPY.as_ptr() as *mut u8,
};
let mut output = CRYPT_INTEGER_BLOB::default();
let ok = unsafe {
if protect {
CryptProtectData(
&input,
std::ptr::null(),
&entropy,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
} else {
CryptUnprotectData(
&input,
std::ptr::null_mut(),
&entropy,
std::ptr::null(),
std::ptr::null(),
CRYPTPROTECT_UI_FORBIDDEN,
&mut output,
)
}
};
if ok == 0 || output.pbData.is_null() || output.cbData == 0 {
return Err("CREDENTIAL_AUTO_UNLOCK_FAILED".into());
}
let result = unsafe {
Zeroizing::new(std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec())
};
if !protect {
unsafe { std::ptr::write_bytes(output.pbData, 0, output.cbData as usize) };
}
unsafe { LocalFree(output.pbData as *mut core::ffi::c_void) };
Ok(result)
}
pub fn load(path: &Path) -> Result<Option<Zeroizing<Vec<u8>>>> {
if !path.exists() {
return Ok(None);
}
let metadata = fs::symlink_metadata(path).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() > 64 * 1024 {
return Err("CREDENTIAL_AUTO_UNLOCK_FAILED".into());
}
let bytes = fs::read(path).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
if !bytes.starts_with(MAGIC) || bytes.len() == MAGIC.len() {
return Err("CREDENTIAL_AUTO_UNLOCK_FAILED".into());
}
transform(&bytes[MAGIC.len()..], false).map(Some)
}
pub fn save(path: &Path, secret: &[u8]) -> Result<()> {
let protected = transform(secret, true)?;
let parent = path.parent().ok_or("CREDENTIAL_AUTO_UNLOCK_FAILED")?;
fs::create_dir_all(parent).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
let mut target =
tempfile::NamedTempFile::new_in(parent).map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
target
.write_all(MAGIC)
.and_then(|_| target.write_all(&protected))
.and_then(|_| target.as_file().sync_all())
.map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
target
.persist(path)
.map_err(|_| "CREDENTIAL_AUTO_UNLOCK_FAILED")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dpapi_round_trip_never_persists_plaintext() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("auto-unlock.dpapi");
let secret = b"test-system-secret-123456789";
save(&path, secret).unwrap();
assert!(!fs::read(&path)
.unwrap()
.windows(secret.len())
.any(|part| part == secret));
assert_eq!(load(&path).unwrap().unwrap().as_slice(), secret);
}
}
+74
View File
@@ -354,6 +354,52 @@ pub struct CredentialBroker {
} }
impl CredentialBroker { impl CredentialBroker {
#[cfg(windows)]
fn auto_unlock_path(&self) -> Result<PathBuf> {
Ok(self
.path
.parent()
.ok_or("CREDENTIAL_PATH_INVALID")?
.join("auto-unlock.dpapi"))
}
/// Unlocks with a random secret protected by Windows DPAPI. A new vault is initialized
/// automatically; an existing password vault is never overwritten implicitly.
#[cfg(windows)]
pub fn ensure_system_unlock(&mut self) -> Result<bool> {
let key_path = self.auto_unlock_path()?;
if let Some(secret) = crate::credential_autounlock::load(&key_path)? {
self.unlock(secret)?;
return Ok(true);
}
if self.path.exists() {
return Ok(false);
}
let mut secret = Zeroizing::new(vec![0u8; 32]);
rand::rngs::OsRng
.try_fill_bytes(&mut secret)
.map_err(|_| "CREDENTIAL_ENTROPY_FAILED")?;
crate::credential_autounlock::save(&key_path, &secret)?;
self.unlock(secret)?;
Ok(true)
}
#[cfg(windows)]
pub fn enable_system_unlock(&self, password: &[u8]) -> Result<()> {
self.session()?;
crate::credential_autounlock::save(&self.auto_unlock_path()?, password)
}
#[cfg(windows)]
pub fn has_system_unlock(&self) -> bool {
self.auto_unlock_path().is_ok_and(|path| path.is_file())
}
#[cfg(not(windows))]
pub fn has_system_unlock(&self) -> bool {
false
}
/// 源来自本机文件选择器,而不是原始 WebView 路径。导入是幂等的;冲突的 ID 会停止整个事务。 /// 源来自本机文件选择器,而不是原始 WebView 路径。导入是幂等的;冲突的 ID 会停止整个事务。
pub fn import_fernet( pub fn import_fernet(
&mut self, &mut self,
@@ -1041,6 +1087,34 @@ mod tests {
fn password() -> Zeroizing<Vec<u8>> { fn password() -> Zeroizing<Vec<u8>> {
Zeroizing::new(b"test-only-password-123".to_vec()) Zeroizing::new(b"test-only-password-123".to_vec())
} }
#[cfg(windows)]
#[test]
fn system_unlock_survives_broker_restart() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("credentials/stronghold.v1");
let id = CredentialId {
scope: Scope::Provider,
id: "provider-restart".into(),
};
{
let mut broker = CredentialBroker::new(path.clone());
assert!(broker.ensure_system_unlock().unwrap());
broker
.put(&id, Zeroizing::new(b"restart-secret".to_vec()))
.unwrap();
}
let mut restarted = CredentialBroker::new(path);
assert!(restarted.ensure_system_unlock().unwrap());
assert_eq!(
restarted
.resolve(&Scope::Provider, &id)
.unwrap()
.unwrap()
.as_slice(),
b"restart-secret"
);
}
fn b04_fixture() -> (Vec<u8>, String, BTreeMap<String, String>) { fn b04_fixture() -> (Vec<u8>, String, BTreeMap<String, String>) {
let fixture: serde_json::Value = let fixture: serde_json::Value =
serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap(); serde_json::from_str(include_str!("../tests/fixtures/fernet-python.json")).unwrap();
+2
View File
@@ -2,6 +2,8 @@
pub mod core; pub mod core;
pub mod core_update; pub mod core_update;
#[cfg(windows)]
mod credential_autounlock;
pub mod credentials; pub mod credentials;
mod payloads; mod payloads;
mod preference_records; mod preference_records;
+25 -16
View File
@@ -561,7 +561,10 @@ fn credentials_status(host: State<'_, Host>) -> Result<serde_json::Value, String
.try_lock() .try_lock()
.map_err(|_| "CREDENTIALS_BUSY")?; .map_err(|_| "CREDENTIALS_BUSY")?;
let broker = broker.as_ref().ok_or("HOST_NOT_READY")?; let broker = broker.as_ref().ok_or("HOST_NOT_READY")?;
Ok(serde_json::json!({"locked":broker.is_locked()})) Ok(serde_json::json!({
"locked": broker.is_locked(),
"automatic": if cfg!(windows) { broker.has_system_unlock() } else { false }
}))
} }
#[tauri::command] #[tauri::command]
@@ -578,12 +581,13 @@ async fn credentials_unlock(host: State<'_, Host>, password: String) -> Result<(
let broker = host.credentials.clone(); let broker = host.credentials.clone();
let password = Zeroizing::new(password.into_bytes()); let password = Zeroizing::new(password.into_bytes());
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
broker let mut guard = broker.lock().map_err(|_| "HOST_BUSY")?;
.lock() let broker = guard.as_mut().ok_or("HOST_NOT_READY")?;
.map_err(|_| "HOST_BUSY")? let retained = Zeroizing::new(password.to_vec());
.as_mut() broker.unlock(password)?;
.ok_or("HOST_NOT_READY")? #[cfg(windows)]
.unlock(password) broker.enable_system_unlock(&retained)?;
Ok(())
}) })
.await .await
.map_err(|_| "HOST_BUSY")? .map_err(|_| "HOST_BUSY")?
@@ -705,12 +709,13 @@ async fn credentials_change_password(
let broker = host.credentials.clone(); let broker = host.credentials.clone();
let password = Zeroizing::new(password.into_bytes()); let password = Zeroizing::new(password.into_bytes());
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
broker let mut guard = broker.lock().map_err(|_| "HOST_BUSY")?;
.lock() let broker = guard.as_mut().ok_or("HOST_NOT_READY")?;
.map_err(|_| "HOST_BUSY")? let retained = Zeroizing::new(password.to_vec());
.as_mut() broker.change_password(password)?;
.ok_or("HOST_NOT_READY")? #[cfg(windows)]
.change_password(password) broker.enable_system_unlock(&retained)?;
Ok(())
}) })
.await .await
.map_err(|_| "HOST_BUSY")? .map_err(|_| "HOST_BUSY")?
@@ -948,11 +953,15 @@ fn main() {
.lock() .lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(extension_store); .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(extension_store);
let credential_state = app.state::<Host>().credentials.clone(); let credential_state = app.state::<Host>().credentials.clone();
let mut broker =
CredentialBroker::new(app.path().app_data_dir()?.join("credentials/stronghold.v1"));
#[cfg(windows)]
broker
.ensure_system_unlock()
.map_err(std::io::Error::other)?;
*credential_state *credential_state
.lock() .lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(CredentialBroker::new( .map_err(|_| std::io::Error::other("HOST_BUSY"))? = Some(broker);
app.path().app_data_dir()?.join("credentials/stronghold.v1"),
));
let signal = credential_state let signal = credential_state
.lock() .lock()
.map_err(|_| std::io::Error::other("HOST_BUSY"))? .map_err(|_| std::io::Error::other("HOST_BUSY"))?
+34 -2
View File
@@ -1,7 +1,39 @@
{ {
"$schema": "https://schema.tauri.app/config/2",
"bundle": { "bundle": {
"active": true, "active": true,
"targets": ["nsis"], "targets": [
"resources": {"../../.build/sidecar/dist/opennexus-core/": "core/"} "nsis"
],
"publisher": "Kronecker",
"homepage": "https://gitea.kronecker.cc/Kronecker/NotesAgentic",
"icon": [
"icons/icon.png",
"icons/icon.ico"
],
"resources": {
"../../.build/sidecar/dist/opennexus-core/": "core/"
},
"category": "Productivity",
"shortDescription": "本地优先的 AI 笔记与知识中枢",
"windows": {
"allowDowngrades": false,
"webviewInstallMode": {
"type": "downloadBootstrapper",
"silent": true
},
"nsis": {
"installMode": "currentUser",
"languages": [
"SimpChinese",
"English"
],
"displayLanguageSelector": false,
"compression": "lzma",
"installerIcon": "icons/icon.ico",
"uninstallerIcon": "icons/icon.ico",
"startMenuFolder": "OpenNexus"
}
}
} }
} }
+23 -4
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "OpenNexus", "productName": "OpenNexus",
"version": "0.3.1-alpha.1", "version": "0.5.0",
"identifier": "cc.kronecker.notesagent", "identifier": "cc.kronecker.notesagent",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev",
@@ -10,11 +10,30 @@
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
"windows": [{"label": "main", "title": "OpenNexus", "width": 1280, "height": 960, "minWidth": 720, "minHeight": 700, "center": true, "decorations": false}], "windows": [
{
"label": "main",
"title": "OpenNexus",
"width": 1280,
"height": 960,
"minWidth": 720,
"minHeight": 700,
"center": true,
"decorations": false
}
],
"security": { "security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'self' blob:; object-src 'none'; base-uri 'self'", "csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src 'self' blob:; object-src 'none'; base-uri 'self'",
"capabilities": ["main"] "capabilities": [
"main"
]
} }
}, },
"bundle": {"active": false} "bundle": {
"active": false,
"icon": [
"icons/icon.png",
"icons/icon.ico"
]
}
} }
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-labelledby="title desc">
<title id="title">OpenNexus</title>
<desc id="desc">蓝紫色线框文档标志</desc>
<rect x="12" y="12" width="1000" height="1000" rx="252" fill="#fbfcff" stroke="#e1e5f0" stroke-width="24"/>
<path d="M254 184h322l194 194v462H254z" fill="none" stroke="#6269f6" stroke-width="48" stroke-linejoin="round"/>
<path d="M576 184v194h194" fill="none" stroke="#6269f6" stroke-width="48" stroke-linejoin="round"/>
<path d="M382 508h260M382 622h260M382 736h260" fill="none" stroke="#6269f6" stroke-width="42" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 651 B

+10 -1
View File
@@ -6,6 +6,7 @@ import { useEditorStore } from '@/stores/editor'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { Moon, Sunny } from '@element-plus/icons-vue' import { Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from './AppIcon.vue' import AppIcon from './AppIcon.vue'
import appLogoUrl from '@/assets/opennexus-logo.svg'
import { t } from '@/i18n' import { t } from '@/i18n'
import { isDesktop } from '@/services/platform/desktop' import { isDesktop } from '@/services/platform/desktop'
import { minimizeWindow, requestWindowClose, toggleMaximizeWindow } from '@/services/platform/windowControls' import { minimizeWindow, requestWindowClose, toggleMaximizeWindow } from '@/services/platform/windowControls'
@@ -63,7 +64,7 @@ function toggleFromTitlebar(event: MouseEvent) {
</span> </span>
</div> </div>
<div class="titlebar-center" data-tauri-drag-region> <div class="titlebar-center" data-tauri-drag-region>
<span class="app-name" data-tauri-drag-region>OpenNexus</span> <span class="app-name" data-tauri-drag-region><img :src="appLogoUrl" alt="" />OpenNexus</span>
</div> </div>
<div class="titlebar-right"> <div class="titlebar-right">
<button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? t('切换浅色主题', 'Switch to light theme') : t('切换深色主题', 'Switch to dark theme')"> <button class="icon-btn" @click="themeStore.toggleTheme()" :title="themeStore.isDark ? t('切换浅色主题', 'Switch to light theme') : t('切换深色主题', 'Switch to dark theme')">
@@ -142,6 +143,9 @@ function toggleFromTitlebar(event: MouseEvent) {
} }
.app-name { .app-name {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px; padding: 3px 10px;
border: 1px solid var(--color-border-subtle); border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-full); border-radius: var(--radius-full);
@@ -151,6 +155,11 @@ function toggleFromTitlebar(event: MouseEvent) {
letter-spacing: .04em; letter-spacing: .04em;
} }
.app-name img {
width: 18px;
height: 18px;
}
.titlebar-right { .titlebar-right {
display: flex; display: flex;
align-items: center; align-items: center;
+13 -3
View File
@@ -21,7 +21,7 @@ const { openCitation } = useCitationNavigation()
const pageError = ref('') const pageError = ref('')
const form = reactive({ const form = reactive({
input: '', provider_id: '', model: '', skill_id: '', max_steps: 10, input: '', provider_id: '', model: '', skill_id: '', max_steps: 10,
tool_timeout_seconds: 30, run_timeout_seconds: 300, token_budget: 8000, tool_timeout_seconds: 30, run_timeout_seconds: 300, limit_token_budget: false, token_budget: 8000,
allow_network: false, max_concurrent_tools: 1, allowed_tools: [] as string[], allow_network: false, max_concurrent_tools: 1, allowed_tools: [] as string[],
}) })
@@ -61,7 +61,7 @@ async function createRun() {
input: form.input, provider_id: form.provider_id, model: form.model, input: form.input, provider_id: form.provider_id, model: form.model,
skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools, skill_id: form.skill_id || undefined, allowed_tools: form.allowed_tools,
max_steps: form.max_steps, tool_timeout_seconds: form.tool_timeout_seconds, max_steps: form.max_steps, tool_timeout_seconds: form.tool_timeout_seconds,
run_timeout_seconds: form.run_timeout_seconds, token_budget: form.token_budget, run_timeout_seconds: form.run_timeout_seconds, token_budget: form.limit_token_budget ? form.token_budget : null,
allow_network: form.allow_network, max_concurrent_tools: form.max_concurrent_tools, allow_network: form.allow_network, max_concurrent_tools: form.max_concurrent_tools,
}) })
await router.replace({ name: 'agent', params: { runId: run.run_id } }) await router.replace({ name: 'agent', params: { runId: run.run_id } })
@@ -101,7 +101,7 @@ async function handleOpenCitation(data: Record<string, unknown>) {
<div class="field"><label>{{ t('最大步骤', 'Maximum steps') }}</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div> <div class="field"><label>{{ t('最大步骤', 'Maximum steps') }}</label><input v-model.number="form.max_steps" class="input" type="number" min="1" max="100" /></div>
<div class="field"><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div> <div class="field"><label>{{ t('工具超时(秒)', 'Tool timeout (seconds)') }}</label><input v-model.number="form.tool_timeout_seconds" class="input" type="number" min="1" /></div>
<div class="field"><label>{{ t('运行超时(秒)', 'Run timeout (seconds)') }}</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div> <div class="field"><label>{{ t('运行超时(秒)', 'Run timeout (seconds)') }}</label><input v-model.number="form.run_timeout_seconds" class="input" type="number" min="1" /></div>
<div class="field"><label>{{ t('令牌预算', 'Token budget') }}</label><input v-model.number="form.token_budget" class="input" type="number" min="1" /></div> <div class="field budget-field"><label><input v-model="form.limit_token_budget" type="checkbox" />{{ t('限制令牌消耗', 'Limit token usage') }}</label><input v-if="form.limit_token_budget" v-model.number="form.token_budget" class="input" type="number" min="1" :aria-label="t('令牌上限', 'Token limit')" /><small v-else class="subtle">{{ t('默认不限制仍可随时取消运行', 'Unlimited by default; the run can still be cancelled at any time.') }}</small></div>
<div class="field"><label>{{ t('最大并发工具', 'Maximum concurrent tools') }}</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div> <div class="field"><label>{{ t('最大并发工具', 'Maximum concurrent tools') }}</label><input v-model.number="form.max_concurrent_tools" class="input" type="number" min="1" /></div>
</div> </div>
<div class="field"><label>{{ t('允许使用的工具', 'Allowed tools') }}</label><div class="tool-grid"><ToolOption v-for="tool in agentStore.tools" :key="tool.name" :name="tool.name" :description="tool.description" :selected="form.allowed_tools.includes(tool.name)" @toggle="toggleTool" /></div></div> <div class="field"><label>{{ t('允许使用的工具', 'Allowed tools') }}</label><div class="tool-grid"><ToolOption v-for="tool in agentStore.tools" :key="tool.name" :name="tool.name" :description="tool.description" :selected="form.allowed_tools.includes(tool.name)" @toggle="toggleTool" /></div></div>
@@ -150,6 +150,16 @@ async function handleOpenCitation(data: Record<string, unknown>) {
<style scoped> <style scoped>
.agent-page > * { width: min(100%, 1080px); margin-inline: auto; } .agent-page > * { width: min(100%, 1080px); margin-inline: auto; }
.run-form { display: grid; gap: var(--space-xl); } .run-form { display: grid; gap: var(--space-xl); }
.budget-field {
min-height: 78px;
align-content: center;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
}
.budget-field > label { color: var(--color-text-primary); }
.budget-field > .input { background: var(--color-surface-primary); }
.tool-grid { display: grid; align-items: start; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); } .tool-grid { display: grid; align-items: start; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-sm); }
.network { display: flex; gap: var(--space-sm); } .network { display: flex; gap: var(--space-sm); }
.trace-layout { display: grid; gap: var(--space-lg); } .trace-layout { display: grid; gap: var(--space-lg); }
+41 -8
View File
@@ -9,8 +9,10 @@ import { useRoute } from 'vue-router'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService' import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
import { localeTag, t } from '@/i18n' import { localeTag, t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue' import FilePicker from '@/components/common/FilePicker.vue'
import { useProviderStore } from '@/stores/provider'
const route = useRoute() const route = useRoute()
const providerStore = useProviderStore()
const maxUploadMiB = isDesktop() ? 64 : 128 const maxUploadMiB = isDesktop() ? 64 : 128
const submission = createMediaSubmission() const submission = createMediaSubmission()
const updateExisting = ref(false) const updateExisting = ref(false)
@@ -44,6 +46,10 @@ const error = ref('')
const notice = ref('') const notice = ref('')
const dirty = ref(false) const dirty = ref(false)
const title = ref(t('课堂转写', 'Class transcript')) const title = ref(t('课堂转写', 'Class transcript'))
const knowledgeTitle = ref(t('课堂知识点笔记', 'Class knowledge notes'))
const providerId = ref('')
const model = ref('')
const models = computed(() => providerStore.modelsByProvider[providerId.value] ?? [])
const player = ref<HTMLAudioElement | null>(null) const player = ref<HTMLAudioElement | null>(null)
const position = ref(0) const position = ref(0)
const speed = ref(1) const speed = ref(1)
@@ -58,6 +64,7 @@ const warningLabel = (warning: string) => warning.startsWith('MEDIA_CORRUPT_PACK
? t(`已跳过 ${warning.split(':')[1]} 个损坏音频包;缺失时长以静音保留,请校对受影响内容。`, `Skipped ${warning.split(':')[1]} damaged audio packets; missing duration was retained as silence. Review the affected content.`) ? t(`已跳过 ${warning.split(':')[1]} 个损坏音频包;缺失时长以静音保留,请校对受影响内容。`, `Skipped ${warning.split(':')[1]} damaged audio packets; missing duration was retained as silence. Review the affected content.`)
: ({ : ({
DIARIZATION_UNAVAILABLE: t('当前无法分离说话人', 'Speaker identification is unavailable'), DIARIZATION_UNAVAILABLE: t('当前无法分离说话人', 'Speaker identification is unavailable'),
DIARIZATION_PARTIAL: t('部分片段没有足够语音用于说话人识别,请人工校对', 'Some segments do not contain enough speech for speaker identification; review them manually'),
WORD_TIMESTAMPS_UNAVAILABLE: t('未提供逐字时间戳', 'Word-level timestamps are unavailable'), WORD_TIMESTAMPS_UNAVAILABLE: t('未提供逐字时间戳', 'Word-level timestamps are unavailable'),
DIARIZATION_SEGMENT_LEVEL: t('说话人按音频段估计,同段多人或重叠发言需人工校对', 'Speakers are estimated per segment; multiple or overlapping speakers require manual correction'), DIARIZATION_SEGMENT_LEVEL: t('说话人按音频段估计,同段多人或重叠发言需人工校对', 'Speakers are estimated per segment; multiple or overlapping speakers require manual correction'),
} as Record<string, string>)[warning] || warning } as Record<string, string>)[warning] || warning
@@ -120,23 +127,40 @@ async function compareSpeaker() {
} }
}) })
} }
async function createArtifacts() {
if (!selected.value || !providerId.value || !model.value.trim()) return
await action(async () => {
const result = await mediaService.artifacts(selected.value!.job_id, {
title: title.value,
knowledge_title: knowledgeTitle.value,
provider_id: providerId.value,
model: model.value,
update_existing: updateExisting.value,
})
notice.value = t(
`已生成完整转录稿“${result.transcript.title}”和知识点笔记“${result.knowledge_note.title}”。`,
`Created transcript “${result.transcript.title}” and knowledge notes “${result.knowledge_note.title}”.`,
)
})
}
function loaded() { if (player.value) player.value.playbackRate = speed.value; const seconds = Number(route.query.time || 0); if (Number.isFinite(seconds) && seconds >= 0) seek(seconds) } function loaded() { if (player.value) player.value.playbackRate = speed.value; const seconds = Number(route.query.time || 0); if (Number.isFinite(seconds) && seconds >= 0) seek(seconds) }
onMounted(async () => { onMounted(async () => {
await refresh() await Promise.all([refresh(), providerStore.loadProviders()])
providerId.value = providerStore.defaultProviderId
if (typeof route.query.job === 'string') { if (typeof route.query.job === 'string') {
try { selected.value = await mediaService.get(route.query.job) } catch (e) { error.value = (e as Error).message } try { selected.value = await mediaService.get(route.query.job) } catch (e) { error.value = (e as Error).message }
} }
}) })
watch(providerId, async (value) => {
model.value = providerStore.providers.find(item => item.provider_id === value)?.default_model ?? ''
if (!value) return
try { await providerStore.loadModels(value) } catch { /* 允许手动填写模型 ID。 */ }
})
onUnmounted(() => { stopped = true; clearTimeout(timer) }) onUnmounted(() => { stopped = true; clearTimeout(timer) })
</script> </script>
<template> <template>
<section class="media-page"> <section class="media-page">
<details class="ui-disclosure">
<summary>{{ t('当前转写能力与验收范围', 'Transcription capabilities and validation') }}</summary>
<p>{{ t('本地转写提供片段级时间戳与说话人聚类,不提供逐字强制对齐或重叠语音分离。聚类编号不代表已确认的真实人数。', 'Local transcription provides segment timestamps and speaker clusters, without forced word alignment or overlapping speech separation. Cluster IDs are not verified speaker counts.') }}</p>
<p>{{ t('无参考转写或说话人标注时,只能验证功能与耗时,不能据此判断准确率。请通过播放与人工校对确认内容。', 'Without reference transcripts or speaker labels, runs validate functionality and timing, not accuracy. Review the audio and correct the transcript.') }}</p>
</details>
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" /> <ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t(`上传音频或视频音轨,转写、校对后保存到知识库。最多 ${maxUploadMiB} MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。`, `Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to ${maxUploadMiB} MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.`) }}</p></div></header> <header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t(`上传音频或视频音轨,转写、校对后保存到知识库。最多 ${maxUploadMiB} MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。`, `Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to ${maxUploadMiB} MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.`) }}</p></div></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p> <div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
@@ -180,7 +204,16 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">{{ t('修订历史', 'Revision history') }}</button></div> <button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">{{ t('修订历史', 'Revision history') }}</button></div>
<details class="ui-disclosure"><summary>{{ t('原始识别文本', 'Original recognition text') }}</summary><pre>{{ selected.original_text }}</pre></details> <details class="ui-disclosure"><summary>{{ t('原始识别文本', 'Original recognition text') }}</summary><pre>{{ selected.original_text }}</pre></details>
<details v-for="revision in history" :key="revision.revision" class="ui-disclosure"><summary>{{ t('修订', 'Revision') }} {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details> <details v-for="revision in history" :key="revision.revision" class="ui-disclosure"><summary>{{ t('修订', 'Revision') }} {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details>
<div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />{{ t('更新上次导出的笔记(已手动修改则拒绝)', 'Update the previously exported note (refuse if manually edited)') }}</label><input v-model="title" class="input" :aria-label="t('笔记标题', 'Note title')" /><button class="button-primary" :disabled="busy || dirty || !title.trim()" @click="action(async () => { const note = await mediaService.note(selected!.job_id, title, updateExisting); notice = `${t('已保存笔记:', 'Saved note: ')}${note.title}` })">{{ t('保存为笔记', 'Save as note') }}</button></div> <section class="artifact-panel">
<div><h3>{{ t('生成课程材料', 'Create course materials') }}</h3><p class="subtle">{{ t('一次生成两份内容:带时间戳的完整转录稿,以及由所选模型提取的知识点笔记。', 'Create two outputs: a timestamped full transcript and knowledge notes extracted by the selected model.') }}</p></div>
<div class="artifact-grid">
<label>{{ t('转录稿标题', 'Transcript title') }}<input v-model="title" class="input" /></label>
<label>{{ t('知识点笔记标题', 'Knowledge-note title') }}<input v-model="knowledgeTitle" class="input" /></label>
<label>{{ t('模型提供商', 'Model provider') }}<select v-model="providerId" class="select"><option value="">{{ t('请选择', 'Select') }}</option><option v-for="item in providerStore.enabledProviders" :key="item.provider_id" :value="item.provider_id">{{ item.name }}</option></select></label>
<label>{{ t('知识提取模型', 'Knowledge extraction model') }}<input v-model="model" class="input" list="media-models" :placeholder="t('填写模型 ID', 'Enter model ID')" /><datalist id="media-models"><option v-for="item in models" :key="item.model_id" :value="item.model_id">{{ item.name }}</option></datalist></label>
</div>
<div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />{{ t('安全更新上次导出的转录稿', 'Safely update the last exported transcript') }}</label><button class="button-primary" :disabled="busy || dirty || !title.trim() || !knowledgeTitle.trim() || !providerId || !model.trim()" @click="createArtifacts">{{ busy ? t('生成中', 'Creating') : t('生成转录稿与知识点笔记', 'Create transcript and knowledge notes') }}</button></div>
</section>
</template> </template>
</article> </article>
<div v-else class="panel subtle">{{ t('选择任务查看转写结果。', 'Select a job to view its transcript.') }}</div> <div v-else class="panel subtle">{{ t('选择任务查看转写结果。', 'Select a job to view its transcript.') }}</div>
@@ -190,5 +223,5 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
<style scoped> <style scoped>
.media-page > :is(.feature-header, .panel, .media-columns, .error-banner) { width: 100%; max-width: 1180px; margin-inline: auto; } .media-page > :is(.feature-header, .panel, .media-columns, .error-banner) { width: 100%; max-width: 1180px; margin-inline: auto; }
.media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.upload-options{display:flex;flex-wrap:wrap;gap:16px}.upload-actions{justify-content:flex-end}.media-columns{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.panel{padding:20px}.job-row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;padding:12px;background:transparent;border:1px solid var(--color-border-default);border-radius:10px;margin-bottom:8px;cursor:pointer;color:inherit}.job-row small{overflow:hidden;text-overflow:ellipsis;max-width:100%}.selected,.current{background:var(--color-background-hover);outline:1px solid var(--color-accent-primary)}.transcript{display:flex;flex-direction:column;gap:16px}.transcript header,.segment{display:flex;gap:12px;align-items:center}.transcript>.button-danger{align-self:flex-start}.transcript>label{white-space:nowrap}.transcript>label select{width:160px}.segment textarea{flex:1}.speaker-names{display:flex;flex-wrap:wrap;gap:10px}audio{width:100%;border-radius:var(--radius-md);accent-color:var(--color-accent-primary)}pre{white-space:pre-wrap;word-break:break-word}label{display:flex;gap:8px;align-items:center}@media(max-width:850px){.media-columns{grid-template-columns:1fr}.segment{flex-wrap:wrap}}@media(max-width:560px){.upload-actions>*{flex:1}.upload-options{flex-direction:column}} .media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.upload-options{display:flex;flex-wrap:wrap;gap:16px}.upload-actions{justify-content:flex-end}.media-columns{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.panel{padding:20px}.job-row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;padding:12px;background:transparent;border:1px solid var(--color-border-default);border-radius:10px;margin-bottom:8px;cursor:pointer;color:inherit}.job-row small{overflow:hidden;text-overflow:ellipsis;max-width:100%}.selected,.current{background:var(--color-background-hover);outline:1px solid var(--color-accent-primary)}.transcript{display:flex;flex-direction:column;gap:16px}.transcript header,.segment{display:flex;gap:12px;align-items:center}.transcript>.button-danger{align-self:flex-start}.transcript>label{white-space:nowrap}.transcript>label select{width:160px}.segment textarea{flex:1}.speaker-names{display:flex;flex-wrap:wrap;gap:10px}.artifact-panel{display:grid;gap:14px;padding:18px;border:1px solid var(--color-border-default);border-radius:var(--radius-lg);background:var(--color-surface-secondary)}.artifact-panel h3,.artifact-panel p{margin:0}.artifact-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.artifact-grid label{align-items:stretch;flex-direction:column;color:var(--color-text-secondary)}.artifact-grid :is(.input,.select){background:var(--color-surface-primary);color:var(--color-text-primary)}audio{width:100%;border-radius:var(--radius-md);accent-color:var(--color-accent-primary)}pre{white-space:pre-wrap;word-break:break-word}label{display:flex;gap:8px;align-items:center}@media(max-width:850px){.media-columns{grid-template-columns:1fr}.segment{flex-wrap:wrap}}@media(max-width:620px){.artifact-grid{grid-template-columns:1fr}}@media(max-width:560px){.upload-actions>*{flex:1}.upload-options{flex-direction:column}}
</style> </style>
@@ -4,6 +4,7 @@ import { hostInvoke } from '@/services/platform/desktop'
import { t } from '@/i18n' import { t } from '@/i18n'
const locked = ref(true) const locked = ref(true)
const automatic = ref(false)
const busy = ref(false) const busy = ref(false)
const password = ref('') const password = ref('')
const confirmation = ref('') const confirmation = ref('')
@@ -15,8 +16,9 @@ function failureMessage(error: unknown, fallback: string) {
: code : code
} }
async function refresh() { async function refresh() {
const state = await hostInvoke<{ locked: boolean }>('credentials_status') const state = await hostInvoke<{ locked: boolean; automatic?: boolean }>('credentials_status')
locked.value = state.locked locked.value = state.locked
automatic.value = state.automatic === true
} }
async function importLegacy() { async function importLegacy() {
busy.value = true; message.value = '' busy.value = true; message.value = ''
@@ -81,8 +83,8 @@ onUnmounted(() => clearInterval(statusTimer))
<template> <template>
<section class="panel settings-section credential-vault" aria-labelledby="credential-vault-title"> <section class="panel settings-section credential-vault" aria-labelledby="credential-vault-title">
<h2 id="credential-vault-title">{{ t('设备凭据保险库', 'Device credential vault') }}</h2> <h2 id="credential-vault-title">{{ t('设备凭据保险库', 'Device credential vault') }}</h2>
<p>{{ locked ? t('已锁定:使用模型密钥前请解锁。首次解锁将创建本机保险库。', 'Locked: unlock before using provider credentials. The first unlock creates this devices vault.') : t('已解锁:密钥仅由本机受控调用使用。', 'Unlocked: credentials are available to authorized local calls.') }}</p> <p>{{ locked ? t('已锁定:使用模型密钥前请解锁。', 'Locked: unlock before using provider credentials.') : automatic ? t('已自动解锁:凭据由当前 Windows 用户的系统加密保护。', 'Automatically unlocked: credentials are protected for the current Windows user.') : t('已解锁:密钥仅由本机受控调用使用。', 'Unlocked: credentials are available to authorized local calls.') }}</p>
<p class="subtle">{{ t('口令至少12个字符。遗失口令后需恢复备份或重新配置密钥;笔记仍可使用。', 'Use at least 12 characters. A lost password requires a backup or re-entering credentials; notes remain available.') }}</p> <p class="subtle">{{ automatic ? t('应用重启后会自动解锁;Windows 锁屏仍会立即撤销当前会话。', 'The vault unlocks automatically after an app restart; locking Windows still revokes the current session immediately.') : t('口令至少12个字符。成功解锁后将为当前 Windows 用户启用自动解锁。', 'Use at least 12 characters. A successful unlock enables automatic unlock for the current Windows user.') }}</p>
<form @submit.prevent="act(locked ? 'unlock' : 'change_password')"> <form @submit.prevent="act(locked ? 'unlock' : 'change_password')">
<label>{{ locked ? t('解锁口令', 'Vault password') : t('新口令', 'New password') }} <label>{{ locked ? t('解锁口令', 'Vault password') : t('新口令', 'New password') }}
<input v-model="password" type="password" minlength="12" maxlength="1024" required autocomplete="off" :disabled="busy" /> <input v-model="password" type="password" minlength="12" maxlength="1024" required autocomplete="off" :disabled="busy" />
+10 -7
View File
@@ -4,8 +4,9 @@ import { useRouter } from 'vue-router'
import { useWorkspaceStore } from '@/stores/workspace' import { useWorkspaceStore } from '@/stores/workspace'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { ArrowRight, Document, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue' import { ArrowRight, Folder, FolderOpened, Moon, Sunny } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue' import AppIcon from '@/components/common/AppIcon.vue'
import appLogoUrl from '@/assets/opennexus-logo.svg'
import { t } from '@/i18n' import { t } from '@/i18n'
import { ApiErrorClass } from '@/services/apiClient' import { ApiErrorClass } from '@/services/apiClient'
import { isDesktop } from '@/services/platform/desktop' import { isDesktop } from '@/services/platform/desktop'
@@ -62,7 +63,7 @@ async function openFolderPicker() {
<div class="bg-decoration" /> <div class="bg-decoration" />
<div class="entry-container"> <div class="entry-container">
<div class="brand-section"> <div class="brand-section">
<div class="logo"><AppIcon :icon="Document" :size="56" /></div> <div class="logo"><img :src="appLogoUrl" alt="" /></div>
<h1 class="app-title">OpenNexus</h1> <h1 class="app-title">OpenNexus</h1>
<p class="app-subtitle">{{ t('本地优先的 AI 笔记软件', 'A local-first AI note-taking app') }}</p> <p class="app-subtitle">{{ t('本地优先的 AI 笔记软件', 'A local-first AI note-taking app') }}</p>
</div> </div>
@@ -109,7 +110,7 @@ async function openFolderPicker() {
</div> </div>
<div class="footer-info"> <div class="footer-info">
<span>v0.1.0</span> <span>v0.5.0</span>
<button class="theme-toggle" @click="themeStore.toggleTheme()"> <button class="theme-toggle" @click="themeStore.toggleTheme()">
<AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="15" /> <AppIcon :icon="themeStore.isDark ? Sunny : Moon" :size="15" />
{{ themeStore.isDark ? t('浅色', 'Light') : t('深色', 'Dark') }} {{ themeStore.isDark ? t('浅色', 'Light') : t('深色', 'Dark') }}
@@ -164,13 +165,15 @@ async function openFolderPicker() {
width: 84px; width: 84px;
height: 84px; height: 84px;
margin-bottom: 14px; margin-bottom: 14px;
border: 1px solid color-mix(in srgb, var(--color-accent-primary) 18%, transparent);
border-radius: 24px;
background: var(--color-surface-primary);
color: var(--color-accent-primary);
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
} }
.logo img {
display: block;
width: 84px;
height: 84px;
}
.app-title { .app-title {
font-size: 32px; font-size: 32px;
font-weight: 700; font-weight: 700;
+1 -1
View File
@@ -39,7 +39,7 @@ export interface CreateAgentRunRequest {
max_steps?: number max_steps?: number
tool_timeout_seconds?: number tool_timeout_seconds?: number
run_timeout_seconds?: number run_timeout_seconds?: number
token_budget?: number token_budget?: number | null
allow_network?: boolean allow_network?: boolean
max_concurrent_tools?: number max_concurrent_tools?: number
} }
+2
View File
@@ -21,6 +21,8 @@ export const mediaService = {
}), }),
revisions: (id: string) => apiClient.get<{items: MediaJob[]}>(`/api/media/transcriptions/${encodeURIComponent(id)}/revisions`), revisions: (id: string) => apiClient.get<{items: MediaJob[]}>(`/api/media/transcriptions/${encodeURIComponent(id)}/revisions`),
note: (id: string, title: string, update_existing = false) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title, update_existing }), note: (id: string, title: string, update_existing = false) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title, update_existing }),
artifacts: (id: string, body: {title: string; knowledge_title?: string; provider_id: string; model: string; update_existing?: boolean}) =>
apiClient.post<{transcript: {note_id: string; title: string}; knowledge_note: {note_id: string; title: string}}>(`/api/media/transcriptions/${encodeURIComponent(id)}/artifacts`, body),
audio: (id: string) => resolveApiUrl(`/api/media/attachments/${encodeURIComponent(id)}`), audio: (id: string) => resolveApiUrl(`/api/media/attachments/${encodeURIComponent(id)}`),
impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`), impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`),
purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`), purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
+1 -1
View File
@@ -6,7 +6,7 @@ import paper from '@/assets/themes/paper-moments.theme?raw'
afterEach(() => vi.unstubAllGlobals()) afterEach(() => vi.unstubAllGlobals())
it('uses the desktop release version for compatibility checks', () => { it('uses the desktop release version for compatibility checks', () => {
expect(THEME_APP_VERSION).toBe('0.3.1-alpha.1') expect(THEME_APP_VERSION).toBe('0.5.0')
}) })
it.each(['999.0.0', 'bad', '0.2'])('rejects unsupported minimum app version %s at inspection and install', async version => { it.each(['999.0.0', 'bad', '0.2'])('rejects unsupported minimum app version %s at inspection and install', async version => {
const source = paper.replace(/min_app_version:.*\r?\n/, `min_app_version: ${version}\n`) const source = paper.replace(/min_app_version:.*\r?\n/, `min_app_version: ${version}\n`)
+7
View File
@@ -61,6 +61,13 @@ def main():
"--name", "opennexus-core", "--distpath", str(output / "dist"), "--name", "opennexus-core", "--distpath", str(output / "dist"),
"--workpath", str(output / "work"), "--specpath", str(output), "--workpath", str(output / "work"), "--specpath", str(output),
"--collect-submodules", "app", "--collect-all", "sqlite_vec", "--collect-submodules", "app", "--collect-all", "sqlite_vec",
# 本地模型使用独立 Python 进程执行;冻结后的 Core 必须保留可直接运行的 Worker 源文件。
"--add-data", str(ROOT / "backend" / "app" / "local_models" / "worker.py") + ":app/local_models",
"--add-data", str(ROOT / "backend" / "app" / "local_models" / "protocol.py") + ":app/local_models",
# 设置页可以在可写的数据目录中安装模型运行环境,安装器及其锁文件需随 Core 发布。
"--add-data", str(ROOT / "backend" / "scripts" / "install-model-runtime.ps1") + ":scripts",
"--add-data", str(ROOT / "backend" / "scripts" / "model-requirements.lock") + ":scripts",
"--add-data", str(ROOT / "backend" / "scripts" / "model-requirements.txt") + ":scripts",
"--add-data", str(ROOT / "backend" / "extensions" / "plugins" / "text-tools") + ":extensions/plugins/text-tools", "--add-data", str(ROOT / "backend" / "extensions" / "plugins" / "text-tools") + ":extensions/plugins/text-tools",
"--add-data", str(ROOT / "backend" / "extensions" / "plugins" / "chat-policy") + ":extensions/plugins/chat-policy", "--add-data", str(ROOT / "backend" / "extensions" / "plugins" / "chat-policy") + ":extensions/plugins/chat-policy",
"--add-data", str(ROOT / "backend" / "extensions" / "skills" / "knowledge-assistant") + ":extensions/skills/knowledge-assistant", "--add-data", str(ROOT / "backend" / "extensions" / "skills" / "knowledge-assistant") + ":extensions/skills/knowledge-assistant",
+19
View File
@@ -0,0 +1,19 @@
.env
.env.*
!.env.example
.git
.gitea
.venv
__pycache__
*.py[cod]
.pytest_cache
.ruff_cache
console/node_modules
console/dist
tests
tools
backups
data
vault
*.sqlite*
*.db
+2
View File
@@ -1,10 +1,12 @@
# 所有值由部署维护者生成;数据库 URL 中密码须进行 URL 编码。 # 所有值由部署维护者生成;数据库 URL 中密码须进行 URL 编码。
OPENNEXUS_SYNC_TAG=0.3.1-alpha.3
POSTGRES_PASSWORD= POSTGRES_PASSWORD=
SYNC_DATABASE_URL= SYNC_DATABASE_URL=
MINIO_ROOT_USER= MINIO_ROOT_USER=
MINIO_ROOT_PASSWORD= MINIO_ROOT_PASSWORD=
SYNC_ACCESS_KEY_ID= SYNC_ACCESS_KEY_ID=
SYNC_SECRET_ACCESS_KEY= SYNC_SECRET_ACCESS_KEY=
SYNC_S3_BUCKET=opennexus
# 默认只监听本机;仅在已隔离的明文 HTTP 测试阶段显式改为 0.0.0.0。 # 默认只监听本机;仅在已隔离的明文 HTTP 测试阶段显式改为 0.0.0.0。
SYNC_BIND_ADDRESS=127.0.0.1 SYNC_BIND_ADDRESS=127.0.0.1
SYNC_PORT=8080 SYNC_PORT=8080
+5
View File
@@ -7,6 +7,10 @@ COPY console ./
RUN pnpm build RUN pnpm build
FROM python:3.12-slim FROM python:3.12-slim
ARG OPENNEXUS_SYNC_VERSION=0.5.0
LABEL org.opencontainers.image.title="OpenNexus Server Sync" \
org.opencontainers.image.version="${OPENNEXUS_SYNC_VERSION}" \
org.opencontainers.image.source="https://gitea.kronecker.cc/Kronecker/NotesAgentic"
COPY --from=ghcr.io/astral-sh/uv:0.9.24 /uv /bin/uv COPY --from=ghcr.io/astral-sh/uv:0.9.24 /uv /bin/uv
WORKDIR /service WORKDIR /service
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
@@ -16,4 +20,5 @@ COPY --from=console /sync_server/static ./sync_server/static
RUN useradd --uid 10001 --create-home opennexus && mkdir /staging && chown opennexus /staging RUN useradd --uid 10001 --create-home opennexus && mkdir /staging && chown opennexus /staging
USER 10001 USER 10001
ENV SYNC_STAGING_DIR=/staging ENV SYNC_STAGING_DIR=/staging
EXPOSE 8080
CMD ["/service/.venv/bin/python", "-m", "sync_server", "serve"] CMD ["/service/.venv/bin/python", "-m", "sync_server", "serve"]
+32 -7
View File
@@ -1,6 +1,6 @@
# OpenNexus Sync 服务原型 # OpenNexus Server Sync
协议及限制见 [Sync v1](../docs/contracts/Sync-v1契约.md)。服务独立于 AI Core,生产入口仅支持 PostgreSQL 和 S3。当前尚未达到 M3 运维退出条件 当前发布版本为 **0.5.0**协议及限制见 [Sync v1](../docs/contracts/Sync-v1契约.md)。服务独立于 AI Core,生产入口仅支持 PostgreSQL 和 S3 兼容对象存储。独立发布包包含服务源码、锁文件、Vue 3 + TypeScript 管理控制台静态文件、Dockerfile 与 Compose 模板,不包含任何 Vault、账户数据库、对象存储数据或部署密钥
服务根路径 `/``/console/` 提供同源的 Vue 3 + TypeScript Sync Console,可查看服务健康与依赖就绪状态,并使用普通 Sync 账户管理自己的 Vault 和设备。页面只调用公开的 Sync v1 API;密码在请求发出前从输入框清除,访问和刷新令牌只保留在页面内存,刷新或关闭页面即丢弃。控制台源码位于 `console/`,生产静态文件由 Docker 多阶段构建生成。 服务根路径 `/``/console/` 提供同源的 Vue 3 + TypeScript Sync Console,可查看服务健康与依赖就绪状态,并使用普通 Sync 账户管理自己的 Vault 和设备。页面只调用公开的 Sync v1 API;密码在请求发出前从输入框清除,访问和刷新令牌只保留在页面内存,刷新或关闭页面即丢弃。控制台源码位于 `console/`,生产静态文件由 Docker 多阶段构建生成。
@@ -22,17 +22,42 @@ uv run pytest
## 自托管准备 ## 自托管准备
1. 将 `.env.example` 复制为 `.env`,生成独立数据库、MinIO 管理和同步访问凭据。数据库 URL 使用 `postgresql+psycopg://…`,其中密码须 URL 编码 从发布页下载 `OpenNexus-Server-Sync-0.5.0.zip` 并核对 `SHA256.json` 后,将压缩包解压到独立目录。升级现有实例时先备份数据库、对象存储和 `.env`,再使用新版镜像替换 Sync 服务;不要用发行包覆盖持久化卷
仓库提供以下 Docker 文件:
- `Dockerfile`:构建 Vue 控制台和只读运行镜像。
- `compose.yaml`:启动 PostgreSQL、MinIO、一次性初始化任务和 Sync 服务,默认只监听 `127.0.0.1:8080`
- `compose.test.yaml`:仅供隔离验收使用,将 Sync 通过 IPv4 与 IPv6 暴露到 `18080`,并使用 MinIO 管理凭据。
- `.dockerignore`:排除密钥、数据库、Vault、测试缓存和本机依赖。
```powershell
Copy-Item .env.example .env
# 编辑 .env 后启动生产形态
docker compose up -d --build
# 或在隔离测试机直接开放 18080;需要 Docker Compose 2.24.4+
docker compose -f compose.yaml -f compose.test.yaml up -d --build
```
1. 将 `.env.example` 复制为 `.env`,生成独立数据库、MinIO 管理和同步访问凭据。数据库 URL 使用 `postgresql+psycopg://…`,其中密码须 URL 编码。升级现有实例时,将 `SYNC_S3_BUCKET` 保持为原实例的 Bucket 名称。
2. 执行 `docker compose up -d`。一次性 `initialize` 服务等待依赖后幂等创建 schema 与 `opennexus` Bucket;重复运行只检查并补齐缺失资源,不覆盖已有行或对象。长期运行的 `sync` 服务继续使用只限该 Bucket 的同步账号,不使用 MinIO root 身份。 2. 执行 `docker compose up -d`。一次性 `initialize` 服务等待依赖后幂等创建 schema 与 `opennexus` Bucket;重复运行只检查并补齐缺失资源,不覆盖已有行或对象。长期运行的 `sync` 服务继续使用只限该 Bucket 的同步账号,不使用 MinIO root 身份。
3. 执行 `docker compose run --rm sync /service/.venv/bin/python -m sync_server create-user`,密码交互输入,不放命令参数 3. 全新数据库会生成账户 `admin` 和本次启动专用的随机密码。使用 `docker compose logs sync` 查找 `SYNC_BOOTSTRAP_CREDENTIALS`;随机密码不会写入镜像、环境变量或数据库明文。只要账户尚未固定,服务每次重启都会更换该密码并撤销旧会话
4. 使用 Caddy 示例配置 TLS。默认通过 `SYNC_BIND_ADDRESS=127.0.0.1` 4. 使用随机密码首次登录控制台后,必须立即修改账户名和密码。保存成功后凭据写入数据库,此后服务重启不再更换。已有正式账户的升级实例不会额外创建默认账户。仍可使用 `create-user` 运维命令增加独立账户,密码通过终端交互输入。
5. 使用 Caddy 示例配置 TLS。默认通过 `SYNC_BIND_ADDRESS=127.0.0.1`
`SYNC_PORT=8080` 只监听本机。仅限已授权的隔离测试阶段将监听地址改为 `SYNC_PORT=8080` 只监听本机。仅限已授权的隔离测试阶段将监听地址改为
`0.0.0.0` 并直接开放测试端口;该模式不作为生产发布配置。 `0.0.0.0` 并直接开放测试端口;该模式不作为生产发布配置。
5. 检查 `/health``/ready` 及经过授权的上传/读取;`/ready` 探测数据库 schema、staging 读写和对象存储测试前缀。 6. 检查 `/health``/ready` 及经过授权的上传/读取;`/ready` 探测数据库 schema、staging 读写和对象存储测试前缀。
如需让保留旧数据的升级实例执行一次首次设置,可运行下列命令。它只新增临时管理员,不删除旧账户、Vault 或对象;命令输出的随机密码在固定前也会随服务重启而失效。
```powershell
docker compose run --rm sync /service/.venv/bin/python -m sync_server bootstrap-user
```
`initialize` 命令已通过真实 PostgreSQL/MinIO 的空实例与重复运行验证,并由 Compose 的一次性服务调用。MinIO 同步账号仍须由管理员创建并限制到 `opennexus` Bucket`.env` 中的 root 与同步凭据必须不同。 `initialize` 命令已通过真实 PostgreSQL/MinIO 的空实例与重复运行验证,并由 Compose 的一次性服务调用。MinIO 同步账号仍须由管理员创建并限制到 `opennexus` Bucket`.env` 中的 root 与同步凭据必须不同。
2026-09-08 已在独立 Docker 项目完成真实 PostgreSQL/MinIO 双 worker 测试部署,修正基础镜像中的 `sync` 系统用户名冲突。测试专用 HTTP 地址、故障检查、完整验收缺口与运维入口见[验收报告](../docs/development/OpenNexus验收报告-2026-09-08.md)。S-07 已在原生 PostgreSQL 17.11/MinIO 实例完成 1 GiB/10,000 文件的删除源实例与空实例恢复;当前机器没有 Docker CLI,因此修改后的 Compose 编排仍需在发布环境复演,生产 TLS 也仍是独立发布门 0.5.0 使用真实 PostgreSQL/MinIO 环境验证初始化、重复启动、固定凭据、健康检查和已有数据升级。测试专用 HTTP 地址、故障检查、完整验收记录与运维入口见[验收报告](../docs/development/OpenNexus验收报告-2026-09-08.md)。S-07 已在原生 PostgreSQL 17.11/MinIO 实例完成 1 GiB/10,000 文件的删除源实例与空实例恢复。测试阶段可以直接开放 HTTP 端口;生产上线仍需配置 TLS、访问控制、监控与异机备份
## 备份与空实例恢复 ## 备份与空实例恢复
+10
View File
@@ -0,0 +1,10 @@
# 仅用于隔离的明文 HTTP 验收环境;生产部署不要加载此覆盖文件。
services:
sync:
environment:
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?required}
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?required}
ports: !override
- "0.0.0.0:${SYNC_PORT:-18080}:8080"
- "[::]:${SYNC_PORT:-18080}:8080"
restart: unless-stopped
+18 -4
View File
@@ -20,12 +20,16 @@ services:
volumes: volumes:
- objects:/data - objects:/data
initialize: initialize:
build: . build:
context: .
args:
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.5.0}
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.5.0}
command: ["/service/.venv/bin/python", "-m", "sync_server", "initialize"] command: ["/service/.venv/bin/python", "-m", "sync_server", "initialize"]
environment: environment:
SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required} SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required}
SYNC_S3_ENDPOINT: http://objects:9000 SYNC_S3_ENDPOINT: http://objects:9000
SYNC_S3_BUCKET: opennexus SYNC_S3_BUCKET: ${SYNC_S3_BUCKET:-opennexus}
AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?required} AWS_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?required}
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?required} AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?required}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
@@ -36,11 +40,15 @@ services:
condition: service_started condition: service_started
restart: "no" restart: "no"
sync: sync:
build: . build:
context: .
args:
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.5.0}
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.5.0}
environment: environment:
SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required} SYNC_DATABASE_URL: ${SYNC_DATABASE_URL:?required}
SYNC_S3_ENDPOINT: http://objects:9000 SYNC_S3_ENDPOINT: http://objects:9000
SYNC_S3_BUCKET: opennexus SYNC_S3_BUCKET: ${SYNC_S3_BUCKET:-opennexus}
AWS_ACCESS_KEY_ID: ${SYNC_ACCESS_KEY_ID:?required} AWS_ACCESS_KEY_ID: ${SYNC_ACCESS_KEY_ID:?required}
AWS_SECRET_ACCESS_KEY: ${SYNC_SECRET_ACCESS_KEY:?required} AWS_SECRET_ACCESS_KEY: ${SYNC_SECRET_ACCESS_KEY:?required}
AWS_DEFAULT_REGION: us-east-1 AWS_DEFAULT_REGION: us-east-1
@@ -57,6 +65,12 @@ services:
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
cap_drop: [ALL] cap_drop: [ALL]
healthcheck:
test: ["CMD", "/service/.venv/bin/python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/ready', timeout=3).read()"]
interval: 15s
timeout: 5s
retries: 8
start_period: 20s
volumes: volumes:
postgres: postgres:
objects: objects:
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "opennexus-sync-console", "name": "opennexus-sync-console",
"private": true, "private": true,
"version": "0.3.1-alpha.1", "version": "0.5.0",
"packageManager": "pnpm@10.28.0", "packageManager": "pnpm@10.28.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
+53 -3
View File
@@ -11,6 +11,11 @@ const username = ref('')
const password = ref('') const password = ref('')
const deviceName = ref('OpenNexus Web Console') const deviceName = ref('OpenNexus Web Console')
const sessionLabel = ref('') const sessionLabel = ref('')
const credentialsRequired = ref(false)
const currentPassword = ref('')
const newUsername = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const newVaultName = ref('') const newVaultName = ref('')
const vaults = ref<Vault[]>([]) const vaults = ref<Vault[]>([])
const devices = ref<Device[]>([]) const devices = ref<Device[]>([])
@@ -72,6 +77,10 @@ function leaveConsole() {
password.value = '' password.value = ''
vaults.value = [] vaults.value = []
devices.value = [] devices.value = []
credentialsRequired.value = false
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
} }
async function signIn() { async function signIn() {
@@ -82,17 +91,41 @@ async function signIn() {
const device = deviceName.value.trim() const device = deviceName.value.trim()
password.value = '' password.value = ''
try { try {
await api.login(account, secret, device) credentialsRequired.value = await api.login(account, secret, device)
sessionLabel.value = `${account} · ${device}` sessionLabel.value = `${account} · ${device}`
newUsername.value = account
signedIn.value = true signedIn.value = true
await loadAccount() if (!credentialsRequired.value) await loadAccount()
notify('设备会话已建立') notify(credentialsRequired.value ? '请立即固定账户与密码' : '设备会话已建立')
} catch (error) { } catch (error) {
leaveConsole() leaveConsole()
notify(error instanceof Error ? error.message : 'LOGIN_FAILED', true) notify(error instanceof Error ? error.message : 'LOGIN_FAILED', true)
} finally { busy.value = false } } finally { busy.value = false }
} }
async function fixCredentials() {
if (busy.value) return
if (newPassword.value !== confirmPassword.value) {
notify('两次输入的新密码不一致', true)
return
}
busy.value = true
const oldSecret = currentPassword.value
const nextSecret = newPassword.value
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
try {
const result = await api.changeCredentials(oldSecret, newUsername.value.trim(), nextSecret)
credentialsRequired.value = false
sessionLabel.value = `${result.username} · ${deviceName.value.trim()}`
await loadAccount()
notify('账户与密码已固定;以后重启不会再随机更换')
} catch (error) {
notify(error instanceof Error ? error.message : 'CREDENTIAL_CHANGE_FAILED', true)
} finally { busy.value = false }
}
async function createVault() { async function createVault() {
const name = newVaultName.value.trim() const name = newVaultName.value.trim()
if (!name || busy.value) return if (!name || busy.value) return
@@ -198,6 +231,22 @@ onBeforeUnmount(() => {
<button class="secondary-button" type="button" :disabled="busy" @click="logout">退出登录</button> <button class="secondary-button" type="button" :disabled="busy" @click="logout">退出登录</button>
</div> </div>
<section v-if="credentialsRequired" class="login-card credential-card" aria-labelledby="credential-title">
<div class="card-heading">
<span class="lock-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="3" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></svg></span>
<div><p>首次登录</p><h2 id="credential-title">固定账户凭据</h2></div>
</div>
<p class="surface-intro">当前密码只对本次服务启动有效修改账户和密码后凭据将写入数据库并在后续重启中保持不变</p>
<form @submit.prevent="fixCredentials">
<label>当前随机密码<input v-model="currentPassword" type="password" autocomplete="current-password" minlength="12" maxlength="256" required></label>
<label>新账户<input v-model="newUsername" autocomplete="username" maxlength="80" required></label>
<label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label>
<label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label>
<button class="primary-button" type="submit" :disabled="busy || !currentPassword || !newPassword || !confirmPassword">{{ busy ? '正在保存…' : '保存并固定凭据' }}</button>
</form>
</section>
<template v-else>
<div class="metric-grid"> <div class="metric-grid">
<article><span>远端 Vault</span><strong>{{ vaults.length }}</strong><small>当前账户可访问</small></article> <article><span>远端 Vault</span><strong>{{ vaults.length }}</strong><small>当前账户可访问</small></article>
<article><span>已使用空间</span><strong>{{ formatBytes(used) }}</strong><small>总配额 {{ formatBytes(quota) }}</small></article> <article><span>已使用空间</span><strong>{{ formatBytes(used) }}</strong><small>总配额 {{ formatBytes(quota) }}</small></article>
@@ -242,6 +291,7 @@ onBeforeUnmount(() => {
</div> </div>
</section> </section>
</div> </div>
</template>
</section> </section>
</main> </main>
+16 -1
View File
@@ -10,6 +10,7 @@ export interface Session {
refresh_token: string refresh_token: string
expires_in: number expires_in: number
device_id: string device_id: string
must_change_credentials: boolean
} }
export interface Vault { export interface Vault {
@@ -39,6 +40,7 @@ export class SyncApi {
private access = '' private access = ''
private refresh = '' private refresh = ''
deviceId = '' deviceId = ''
mustChangeCredentials = false
get signedIn() { return Boolean(this.access) } get signedIn() { return Boolean(this.access) }
@@ -80,6 +82,7 @@ export class SyncApi {
this.access = session.access_token this.access = session.access_token
this.refresh = session.refresh_token this.refresh = session.refresh_token
this.deviceId = session.device_id this.deviceId = session.device_id
this.mustChangeCredentials = Boolean(session.must_change_credentials)
} }
async status(): Promise<ServiceStatus> { async status(): Promise<ServiceStatus> {
@@ -103,7 +106,7 @@ export class SyncApi {
} }
} }
async login(username: string, password: string, deviceName: string): Promise<void> { async login(username: string, password: string, deviceName: string): Promise<boolean> {
const response = await this.raw('/sync/v1/auth/sessions', { const response = await this.raw('/sync/v1/auth/sessions', {
method: 'POST', method: 'POST',
body: JSON.stringify({ username, password, device_name: deviceName }), body: JSON.stringify({ username, password, device_name: deviceName }),
@@ -115,6 +118,17 @@ export class SyncApi {
const session = await safeJson<Session>(response) const session = await safeJson<Session>(response)
if (!session) throw new Error('INVALID_RESPONSE') if (!session) throw new Error('INVALID_RESPONSE')
this.accept(session) this.accept(session)
return this.mustChangeCredentials
}
async changeCredentials(currentPassword: string, username: string, password: string) {
const result = await this.request<{ username: string; credentials_fixed: boolean }>(
'/sync/v1/account/credentials', {
method: 'PUT', body: JSON.stringify({ current_password: currentPassword, username, password }),
}, false,
)
this.mustChangeCredentials = false
return result
} }
vaults() { return this.request<{ items: Vault[] }>('/sync/v1/vaults') } vaults() { return this.request<{ items: Vault[] }>('/sync/v1/vaults') }
@@ -136,5 +150,6 @@ export class SyncApi {
this.access = '' this.access = ''
this.refresh = '' this.refresh = ''
this.deviceId = '' this.deviceId = ''
this.mustChangeCredentials = false
} }
} }
+1
View File
@@ -90,6 +90,7 @@ main { width: min(1180px, calc(100% - 48px)); margin: 0 auto; position: relative
.protocol-grid strong { font-size: 18px; font-weight: 600; } .protocol-grid strong { font-size: 18px; font-weight: 600; }
.protocol-grid span { color: var(--muted); font-size: 11px; } .protocol-grid span { color: var(--muted); font-size: 11px; }
.login-card { padding: 31px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); position: relative; overflow: hidden; } .login-card { padding: 31px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); position: relative; overflow: hidden; }
.credential-card { width: min(100%, 560px); margin: 32px auto 0; }
.card-glow { position: absolute; width: 210px; height: 210px; right: -100px; top: -120px; border-radius: 50%; background: var(--accent); filter: blur(60px); opacity: .08; } .card-glow { position: absolute; width: 210px; height: 210px; right: -100px; top: -120px; border-radius: 50%; background: var(--accent); filter: blur(60px); opacity: .08; }
.card-heading { display: flex; gap: 14px; align-items: center; margin-bottom: 28px; position: relative; } .card-heading { display: flex; gap: 14px; align-items: center; margin-bottom: 28px; position: relative; }
.card-heading p, .surface-heading p { margin: 0 0 3px; color: var(--accent); font-size: 10px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; } .card-heading p, .surface-heading p { margin: 0 0 3px; color: var(--accent); font-size: 10px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "notesagent-sync" name = "notesagent-sync"
version = "0.3.1a1" version = "0.5.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.116,<1", "uvicorn>=0.35,<1", "sqlalchemy>=2.0,<2.1", "fastapi>=0.116,<1", "uvicorn>=0.35,<1", "sqlalchemy>=2.0,<2.1",
+18 -1
View File
@@ -24,7 +24,7 @@ def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument( parser.add_argument(
"command", "command",
choices=["serve", "initialize", "migrate", "create-user", "cleanup-uploads", "backup", "restore"], choices=["serve", "initialize", "migrate", "create-user", "bootstrap-user", "cleanup-uploads", "backup", "restore"],
) )
parser.add_argument("--workers", type=int, choices=[1, 2], default=2) parser.add_argument("--workers", type=int, choices=[1, 2], default=2)
parser.add_argument("--username") parser.add_argument("--username")
@@ -80,8 +80,25 @@ def main():
elif args.command == "create-user": elif args.command == "create-user":
db.migrate() db.migrate()
db.add_user(args.username or input("用户名: "), getpass.getpass("密码(至少12字符): ")) db.add_user(args.username or input("用户名: "), getpass.getpass("密码(至少12字符): "))
elif args.command == "bootstrap-user":
db.migrate()
bootstrap = db.prepare_bootstrap_user(force=True)
print(json.dumps({
"event": "SYNC_BOOTSTRAP_CREDENTIALS",
"username": bootstrap["username"],
"password": bootstrap["password"],
"must_change_credentials": True,
}), flush=True)
elif args.command == "serve": elif args.command == "serve":
db.migrate() db.migrate()
bootstrap = db.prepare_bootstrap_user()
if bootstrap:
print(json.dumps({
"event": "SYNC_BOOTSTRAP_CREDENTIALS",
"username": bootstrap["username"],
"password": bootstrap["password"],
"must_change_credentials": True,
}), flush=True)
import uvicorn import uvicorn
host = os.environ.get("SYNC_HOST", "0.0.0.0") host = os.environ.get("SYNC_HOST", "0.0.0.0")
if host not in {"0.0.0.0", "127.0.0.1", "::1"}: if host not in {"0.0.0.0", "127.0.0.1", "::1"}:
+33 -4
View File
@@ -19,7 +19,7 @@ from starlette.concurrency import run_in_threadpool
from starlette.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles
from .database import Database, password_hash, row, rows, run from .database import Database, password_hash, row, rows, run
from .models import Commit, Login, Refresh, Upload, VaultCreate from .models import Commit, CredentialChange, Login, Refresh, Upload, VaultCreate
from .readiness import Readiness from .readiness import Readiness
@@ -105,11 +105,14 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
# Pydantic 的原始错误可能带请求正文,禁止回显密码或笔记。 # Pydantic 的原始错误可能带请求正文,禁止回显密码或笔记。
return JSONResponse({"error": {"code": "INVALID_REQUEST", "details": {}}}, status_code=422) return JSONResponse({"error": {"code": "INVALID_REQUEST", "details": {}}}, status_code=422)
def identity(conn, authorization): def identity(conn, authorization, *, allow_bootstrap=False):
token = authorization.removeprefix("Bearer ") if authorization.startswith("Bearer ") else "" token = authorization.removeprefix("Bearer ") if authorization.startswith("Bearer ") else ""
session = row(conn, "SELECT s.*, d.user_id, d.revoked FROM sessions s JOIN devices d ON d.id=s.device_id WHERE s.token=:token", token=digest(token)) session = row(conn, "SELECT s.*, d.user_id, d.revoked FROM sessions s JOIN devices d ON d.id=s.device_id WHERE s.token=:token", token=digest(token))
if not session or session["revoked"] or session["expires"] <= clock(): if not session or session["revoked"] or session["expires"] <= clock():
raise SyncError(401, "SESSION_EXPIRED") raise SyncError(401, "SESSION_EXPIRED")
if not allow_bootstrap and row(conn, "SELECT user_id FROM bootstrap_state WHERE user_id=:user",
user=session["user_id"]):
raise SyncError(403, "CREDENTIAL_CHANGE_REQUIRED")
return session return session
def vault(conn, vault_id, authorization, *, lock=False): def vault(conn, vault_id, authorization, *, lock=False):
@@ -127,7 +130,11 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
run(conn, "INSERT INTO sessions VALUES (:token,:refresh,:device,:expires,:refresh_expires)", run(conn, "INSERT INTO sessions VALUES (:token,:refresh,:device,:expires,:refresh_expires)",
token=digest(access), refresh=digest(refresh), device=device_id, token=digest(access), refresh=digest(refresh), device=device_id,
expires=int(clock()) + 900, refresh_expires=int(clock()) + 30 * 86400) expires=int(clock()) + 900, refresh_expires=int(clock()) + 30 * 86400)
return {"access_token": access, "refresh_token": refresh, "expires_in": 900, "device_id": device_id} device = row(conn, "SELECT user_id FROM devices WHERE id=:id", id=device_id)
must_change = bool(row(conn, "SELECT user_id FROM bootstrap_state WHERE user_id=:user",
user=device["user_id"]))
return {"access_token": access, "refresh_token": refresh, "expires_in": 900,
"device_id": device_id, "must_change_credentials": must_change}
@app.get("/health") @app.get("/health")
def health(response: Response): def health(response: Response):
@@ -210,9 +217,31 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
@app.delete("/sync/v1/auth/sessions", status_code=204) @app.delete("/sync/v1/auth/sessions", status_code=204)
def logout(authorization: str = Header(default="")): def logout(authorization: str = Header(default="")):
with db.transaction() as conn: with db.transaction() as conn:
session = identity(conn, authorization) session = identity(conn, authorization, allow_bootstrap=True)
run(conn, "DELETE FROM sessions WHERE token=:token", token=session["token"]) run(conn, "DELETE FROM sessions WHERE token=:token", token=session["token"])
@app.put("/sync/v1/account/credentials")
def change_credentials(body: CredentialChange, authorization: str = Header(default="")):
with db.transaction() as conn:
session = identity(conn, authorization, allow_bootstrap=True)
user = row(conn, "SELECT * FROM users WHERE id=:id", id=session["user_id"])
expected = user["password"]
if not secrets.compare_digest(
password_hash(body.current_password, expected.split(":")[0]), expected):
raise SyncError(401, "CURRENT_PASSWORD_INVALID")
conflict = row(conn, "SELECT id FROM users WHERE username=:name AND id<>:id",
name=body.username, id=user["id"])
if conflict:
raise SyncError(409, "USERNAME_TAKEN")
run(conn, "UPDATE users SET username=:name,password=:password WHERE id=:id",
name=body.username, password=password_hash(body.password), id=user["id"])
run(conn, "DELETE FROM bootstrap_state WHERE user_id=:user", user=user["id"])
run(conn, "DELETE FROM sessions WHERE device_id IN (SELECT id FROM devices WHERE user_id=:user) AND token<>:token",
user=user["id"], token=session["token"])
run(conn, "UPDATE devices SET revoked=1 WHERE user_id=:user AND id<>:device",
user=user["id"], device=session["device_id"])
return {"username": body.username, "credentials_fixed": True}
@app.get("/sync/v1/devices") @app.get("/sync/v1/devices")
def devices(authorization: str = Header(default="")): def devices(authorization: str = Header(default="")):
with db.transaction() as conn: with db.transaction() as conn:
+32
View File
@@ -3,6 +3,7 @@
from contextlib import contextmanager from contextlib import contextmanager
import hashlib import hashlib
import secrets import secrets
import time
from sqlalchemy import create_engine, text from sqlalchemy import create_engine, text
SCHEMA = [ SCHEMA = [
@@ -17,6 +18,7 @@ SCHEMA = [
"CREATE TABLE IF NOT EXISTS files (vault_id TEXT NOT NULL, file_id TEXT NOT NULL, sequence BIGINT NOT NULL, path_key TEXT NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(vault_id, file_id))", "CREATE TABLE IF NOT EXISTS files (vault_id TEXT NOT NULL, file_id TEXT NOT NULL, sequence BIGINT NOT NULL, path_key TEXT NOT NULL, deleted INTEGER NOT NULL, PRIMARY KEY(vault_id, file_id))",
"CREATE TABLE IF NOT EXISTS login_limits (key TEXT PRIMARY KEY, started BIGINT NOT NULL, attempts INTEGER NOT NULL)", "CREATE TABLE IF NOT EXISTS login_limits (key TEXT PRIMARY KEY, started BIGINT NOT NULL, attempts INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS upload_receipts (id TEXT PRIMARY KEY, vault_id TEXT NOT NULL, device_id TEXT NOT NULL, hash TEXT NOT NULL, completed BIGINT NOT NULL)", "CREATE TABLE IF NOT EXISTS upload_receipts (id TEXT PRIMARY KEY, vault_id TEXT NOT NULL, device_id TEXT NOT NULL, hash TEXT NOT NULL, completed BIGINT NOT NULL)",
"CREATE TABLE IF NOT EXISTS bootstrap_state (user_id TEXT PRIMARY KEY, created BIGINT NOT NULL)",
] ]
@@ -66,6 +68,36 @@ class Database:
conn.execute(text("INSERT INTO users VALUES (:id,:name,:password)"), conn.execute(text("INSERT INTO users VALUES (:id,:name,:password)"),
{"id": secrets.token_hex(16), "name": username, "password": password_hash(password)}) {"id": secrets.token_hex(16), "name": username, "password": password_hash(password)})
def prepare_bootstrap_user(self, *, force=False):
"""在账户尚未固定时生成本次服务启动专用的临时密码。"""
password = secrets.token_urlsafe(24)
with self.transaction() as conn:
state = row(conn, "SELECT user_id FROM bootstrap_state")
user = row(conn, "SELECT * FROM users WHERE id=:id", id=state["user_id"]) if state else None
if state and not user:
run(conn, "DELETE FROM bootstrap_state")
state = None
if not state:
if row(conn, "SELECT id FROM users LIMIT 1") and not force:
return None
user_id = secrets.token_hex(16)
username = "admin"
if row(conn, "SELECT id FROM users WHERE username=:name", name=username):
username = "bootstrap-admin-" + secrets.token_hex(3)
run(conn, "INSERT INTO users VALUES (:id,:name,:password)",
id=user_id, name=username, password=password_hash(password))
run(conn, "INSERT INTO bootstrap_state VALUES (:user,:created)",
user=user_id, created=int(time.time()))
else:
user_id = state["user_id"]
run(conn, "UPDATE users SET password=:password WHERE id=:id",
password=password_hash(password), id=user_id)
run(conn, "DELETE FROM sessions WHERE device_id IN (SELECT id FROM devices WHERE user_id=:user)",
user=user_id)
run(conn, "UPDATE devices SET revoked=1 WHERE user_id=:user", user=user_id)
account = row(conn, "SELECT username FROM users WHERE id=:id", id=user_id)
return {"username": account["username"], "password": password}
def row(conn, sql, **params): def row(conn, sql, **params):
return conn.execute(text(sql), params).mappings().first() return conn.execute(text(sql), params).mappings().first()
+13
View File
@@ -20,6 +20,19 @@ class Refresh(DTO):
refresh_token: str = Field(min_length=32, max_length=256) refresh_token: str = Field(min_length=32, max_length=256)
class CredentialChange(DTO):
current_password: str = Field(min_length=12, max_length=256)
username: str = Field(min_length=1, max_length=80)
password: str = Field(min_length=12, max_length=256)
@field_validator("username")
@classmethod
def username_valid(cls, value):
if value != value.strip():
raise ValueError("账户名首尾不能包含空白")
return value
class VaultCreate(DTO): class VaultCreate(DTO):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
+1
View File
@@ -47,6 +47,7 @@ TABLES: dict[str, tuple[str, ...]] = {
"files": ("vault_id", "file_id", "sequence", "path_key", "deleted"), "files": ("vault_id", "file_id", "sequence", "path_key", "deleted"),
"login_limits": ("key", "started", "attempts"), "login_limits": ("key", "started", "attempts"),
"upload_receipts": ("id", "vault_id", "device_id", "hash", "completed"), "upload_receipts": ("id", "vault_id", "device_id", "hash", "completed"),
"bootstrap_state": ("user_id", "created"),
} }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<meta name="theme-color" content="#07120f"> <meta name="theme-color" content="#07120f">
<title>OpenNexus Sync Console</title> <title>OpenNexus Sync Console</title>
<script type="module" crossorigin src="/console/assets/index-CsQwWg1J.js"></script> <script type="module" crossorigin src="/console/assets/index-C4AkVW4e.js"></script>
<link rel="stylesheet" crossorigin href="/console/assets/index-B8qnzSCe.css"> <link rel="stylesheet" crossorigin href="/console/assets/index-xFodnbVC.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+54
View File
@@ -25,6 +25,18 @@ def env(tmp_path):
db.engine.dispose() db.engine.dispose()
def test_upgrade_can_add_bootstrap_without_removing_existing_accounts(tmp_path):
db = Database("sqlite:///" + str(tmp_path / "upgrade.db"))
db.migrate()
db.add_user("existing", "existing-account-password")
assert db.prepare_bootstrap_user() is None
bootstrap = db.prepare_bootstrap_user(force=True)
assert bootstrap["username"] == "admin"
with db.transaction() as conn:
assert conn.exec_driver_sql("SELECT COUNT(*) FROM users").scalar() == 2
db.engine.dispose()
def session(client, user="alice"): def session(client, user="alice"):
response = client.post("/sync/v1/auth/sessions", json={"username": user, "password": "controlled-fixture-password", "device_name": "测试设备"}) response = client.post("/sync/v1/auth/sessions", json={"username": user, "password": "controlled-fixture-password", "device_name": "测试设备"})
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
@@ -153,3 +165,45 @@ def test_login_limits_and_protocol(env):
for _ in range(10): for _ in range(10):
assert client.post("/sync/v1/auth/sessions", json={"username": "unknown", "password": "controlled-fixture-password", "device_name": "fixture"}).status_code == 401 assert client.post("/sync/v1/auth/sessions", json={"username": "unknown", "password": "controlled-fixture-password", "device_name": "fixture"}).status_code == 401
assert client.post("/sync/v1/auth/sessions", json={"username": "unknown", "password": "controlled-fixture-password", "device_name": "fixture"}).status_code == 429 assert client.post("/sync/v1/auth/sessions", json={"username": "unknown", "password": "controlled-fixture-password", "device_name": "fixture"}).status_code == 429
def test_bootstrap_password_rotates_until_account_is_fixed(tmp_path):
db = Database("sqlite:///" + str(tmp_path / "bootstrap.db"))
db.migrate()
first = db.prepare_bootstrap_user()
second = db.prepare_bootstrap_user()
assert first["username"] == second["username"] == "admin"
assert first["password"] != second["password"]
app = create_app(db, DiskObjects(tmp_path / "objects"), tmp_path / "staging")
with TestClient(app) as client:
old = client.post("/sync/v1/auth/sessions", json={
"username": "admin", "password": first["password"], "device_name": "旧启动",
})
assert old.status_code == 401
login = client.post("/sync/v1/auth/sessions", json={
"username": "admin", "password": second["password"], "device_name": "首次登录",
})
assert login.status_code == 200
assert login.json()["must_change_credentials"] is True
headers = {"Authorization": "Bearer " + login.json()["access_token"]}
blocked = client.get("/sync/v1/vaults", headers=headers)
assert blocked.status_code == 403
assert blocked.json()["error"]["code"] == "CREDENTIAL_CHANGE_REQUIRED"
changed = client.put("/sync/v1/account/credentials", headers=headers, json={
"current_password": second["password"],
"username": "owner",
"password": "fixed-production-password",
})
assert changed.json() == {"username": "owner", "credentials_fixed": True}
assert client.post("/sync/v1/vaults", headers=headers, json={"name": "固定账户"}).status_code == 200
assert db.prepare_bootstrap_user() is None
with TestClient(app) as client:
login = client.post("/sync/v1/auth/sessions", json={
"username": "owner", "password": "fixed-production-password", "device_name": "重启后",
})
assert login.status_code == 200
assert login.json()["must_change_credentials"] is False
db.engine.dispose()
+1 -1
View File
@@ -225,7 +225,7 @@ wheels = [
[[package]] [[package]]
name = "notesagent-sync" name = "notesagent-sync"
version = "0.3.1a1" version = "0.5.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "boto3" }, { name = "boto3" },