Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e86809b238 | ||
|
|
81003d3106 | ||
|
|
9aed039702 |
@@ -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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
OpenNexus 是一款本地优先的 AI 笔记与知识中枢。它将 Markdown Vault、全文与向量检索、知识库问答、可审计 Agent、扩展系统和多设备同步整合在一个桌面应用中。笔记与索引由用户掌控;需要模型或同步服务时,再按需连接本地或远程服务。
|
OpenNexus 是一款本地优先的 AI 笔记与知识中枢。它将 Markdown Vault、全文与向量检索、知识库问答、可审计 Agent、扩展系统和多设备同步整合在一个桌面应用中。笔记与索引由用户掌控;需要模型或同步服务时,再按需连接本地或远程服务。
|
||||||
|
|
||||||
当前发布版本为 **0.3.1-alpha.3**,主要支持 Windows x64。Alpha 版本仍处于快速迭代阶段,升级前请备份 Vault。
|
当前发布版本为 **0.4.0-alpha.1**,主要支持 Windows x64。Alpha 版本仍处于快速迭代阶段,升级前请备份 Vault。
|
||||||
|
|
||||||
## 主要能力
|
## 主要能力
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ flowchart LR
|
|||||||
|
|
||||||
## 使用发布包
|
## 使用发布包
|
||||||
|
|
||||||
本版提供 Windows x64 EXE 安装包和独立的 Server Sync 包,下载入口见 [v0.3.1-alpha.3 发布页](https://gitea.kronecker.cc/Kronecker/NotesAgentic/releases/tag/v0.3.1-alpha.3)。发布页同时附带 `SHA256.json`,用于核对文件完整性。
|
本版提供 Windows x64 EXE 安装包和独立的 Server Sync 包,下载入口见 [v0.4.0-alpha.1 发布页](https://gitea.kronecker.cc/Kronecker/NotesAgentic/releases/tag/v0.4.0-alpha.1)。发布页同时附带 `SHA256.json`,用于核对文件完整性。
|
||||||
|
|
||||||
安装包不包含任何 Vault 或用户数据,也不预装已下载的社区主题、本地模型权重、CUDA 与 PyTorch 运行时。相关功能仍完整保留;需要时可在客户端内按需安装主题、选择模型或配置 CUDA 环境。程序自带的基础界面样式属于客户端资源,不视为社区主题。同一 Windows 用户下升级安装会继续使用 `%APPDATA%\cc.kronecker.notesagent` 中的既有配置和索引,以及用户此前选择的外部 Vault。
|
安装包不包含任何 Vault 或用户数据,也不预装已下载的社区主题、本地模型权重、CUDA 与 PyTorch 运行时。相关功能仍完整保留;需要时可在客户端内按需安装主题、选择模型或配置 CUDA 环境。程序自带的基础界面样式属于客户端资源,不视为社区主题。同一 Windows 用户下升级安装会继续使用 `%APPDATA%\cc.kronecker.notesagent` 中的既有配置和索引,以及用户此前选择的外部 Vault。
|
||||||
|
|
||||||
|
|||||||
@@ -115,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
|
||||||
|
|
||||||
@@ -189,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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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 不变。笔记包含音频与时间跳转链接;重复导出不覆盖用户编辑。清理保留已导出笔记,音频链接失效,已清理任务不可重试。重建索引保留转写与笔记关联。
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notes-agent-frontend",
|
"name": "notes-agent-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.1-alpha.3",
|
"version": "0.4.0-alpha.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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 |
Generated
+1
-1
@@ -3242,7 +3242,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notesagent-desktop"
|
name = "notesagent-desktop"
|
||||||
version = "0.3.1-alpha.3"
|
version = "0.4.0-alpha.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "notesagent-desktop"
|
name = "notesagent-desktop"
|
||||||
version = "0.3.1-alpha.3"
|
version = "0.4.0-alpha.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.89"
|
rust-version = "1.89"
|
||||||
|
|
||||||
|
|||||||
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 |
@@ -8,6 +8,7 @@
|
|||||||
"publisher": "Kronecker",
|
"publisher": "Kronecker",
|
||||||
"homepage": "https://gitea.kronecker.cc/Kronecker/NotesAgentic",
|
"homepage": "https://gitea.kronecker.cc/Kronecker/NotesAgentic",
|
||||||
"icon": [
|
"icon": [
|
||||||
|
"icons/icon.png",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"resources": {
|
"resources": {
|
||||||
|
|||||||
@@ -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.3",
|
"version": "0.4.0-alpha.1",
|
||||||
"identifier": "cc.kronecker.notesagent",
|
"identifier": "cc.kronecker.notesagent",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
@@ -30,6 +30,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": false
|
"active": false,
|
||||||
|
"icon": [
|
||||||
|
"icons/icon.png",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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;
|
||||||
|
|||||||
@@ -58,6 +58,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
|
||||||
|
|||||||
@@ -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.4.0-alpha.1</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;
|
||||||
|
|||||||
@@ -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.2')
|
expect(THEME_APP_VERSION).toBe('0.4.0-alpha.1')
|
||||||
})
|
})
|
||||||
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,7 +7,7 @@ COPY console ./
|
|||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
ARG OPENNEXUS_SYNC_VERSION=0.3.1-alpha.3
|
ARG OPENNEXUS_SYNC_VERSION=0.4.0-alpha.1
|
||||||
LABEL org.opencontainers.image.title="OpenNexus Server Sync" \
|
LABEL org.opencontainers.image.title="OpenNexus Server Sync" \
|
||||||
org.opencontainers.image.version="${OPENNEXUS_SYNC_VERSION}" \
|
org.opencontainers.image.version="${OPENNEXUS_SYNC_VERSION}" \
|
||||||
org.opencontainers.image.source="https://gitea.kronecker.cc/Kronecker/NotesAgentic"
|
org.opencontainers.image.source="https://gitea.kronecker.cc/Kronecker/NotesAgentic"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# OpenNexus Server Sync
|
# OpenNexus Server Sync
|
||||||
|
|
||||||
当前发布版本为 **0.3.1-alpha.3**。协议及限制见 [Sync v1](../docs/contracts/Sync-v1契约.md)。服务独立于 AI Core,生产入口仅支持 PostgreSQL 和 S3 兼容对象存储。独立发布包包含服务源码、锁文件、Vue 3 + TypeScript 管理控制台静态文件、Dockerfile 与 Compose 模板,不包含任何 Vault、账户数据库、对象存储数据或部署密钥。
|
当前发布版本为 **0.4.0-alpha.1**。协议及限制见 [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,13 +22,13 @@ uv run pytest
|
|||||||
|
|
||||||
## 自托管准备
|
## 自托管准备
|
||||||
|
|
||||||
从发布页下载 `OpenNexus-Server-Sync-0.3.1-alpha.3.zip` 并核对 `SHA256.json` 后,将压缩包解压到独立目录。升级现有实例时先备份数据库、对象存储和 `.env`,再使用新版镜像替换 Sync 服务;不要用发行包覆盖持久化卷。
|
从发布页下载 `OpenNexus-Server-Sync-0.4.0-alpha.1.zip` 并核对 `SHA256.json` 后,将压缩包解压到独立目录。升级现有实例时先备份数据库、对象存储和 `.env`,再使用新版镜像替换 Sync 服务;不要用发行包覆盖持久化卷。
|
||||||
|
|
||||||
仓库提供以下 Docker 文件:
|
仓库提供以下 Docker 文件:
|
||||||
|
|
||||||
- `Dockerfile`:构建 Vue 控制台和只读运行镜像。
|
- `Dockerfile`:构建 Vue 控制台和只读运行镜像。
|
||||||
- `compose.yaml`:启动 PostgreSQL、MinIO、一次性初始化任务和 Sync 服务,默认只监听 `127.0.0.1:8080`。
|
- `compose.yaml`:启动 PostgreSQL、MinIO、一次性初始化任务和 Sync 服务,默认只监听 `127.0.0.1:8080`。
|
||||||
- `compose.test.yaml`:仅供隔离验收使用,将 Sync 暴露到 `0.0.0.0:18080` 并使用 MinIO 管理凭据。
|
- `compose.test.yaml`:仅供隔离验收使用,将 Sync 通过 IPv4 与 IPv6 暴露到 `18080`,并使用 MinIO 管理凭据。
|
||||||
- `.dockerignore`:排除密钥、数据库、Vault、测试缓存和本机依赖。
|
- `.dockerignore`:排除密钥、数据库、Vault、测试缓存和本机依赖。
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -57,7 +57,7 @@ docker compose run --rm sync /service/.venv/bin/python -m sync_server bootstrap-
|
|||||||
|
|
||||||
`initialize` 命令已通过真实 PostgreSQL/MinIO 的空实例与重复运行验证,并由 Compose 的一次性服务调用。MinIO 同步账号仍须由管理员创建并限制到 `opennexus` Bucket,`.env` 中的 root 与同步凭据必须不同。
|
`initialize` 命令已通过真实 PostgreSQL/MinIO 的空实例与重复运行验证,并由 Compose 的一次性服务调用。MinIO 同步账号仍须由管理员创建并限制到 `opennexus` Bucket,`.env` 中的 root 与同步凭据必须不同。
|
||||||
|
|
||||||
0.3.1-alpha.3 使用真实 PostgreSQL/MinIO 环境验证初始化、重复启动、固定凭据、健康检查和已有数据升级。测试专用 HTTP 地址、故障检查、完整验收记录与运维入口见[验收报告](../docs/development/OpenNexus验收报告-2026-09-08.md)。S-07 已在原生 PostgreSQL 17.11/MinIO 实例完成 1 GiB/10,000 文件的删除源实例与空实例恢复。测试阶段可以直接开放 HTTP 端口;生产上线仍需配置 TLS、访问控制、监控与异机备份。
|
0.4.0-alpha.1 使用真实 PostgreSQL/MinIO 环境验证初始化、重复启动、固定凭据、健康检查和已有数据升级。测试专用 HTTP 地址、故障检查、完整验收记录与运维入口见[验收报告](../docs/development/OpenNexus验收报告-2026-09-08.md)。S-07 已在原生 PostgreSQL 17.11/MinIO 实例完成 1 GiB/10,000 文件的删除源实例与空实例恢复。测试阶段可以直接开放 HTTP 端口;生产上线仍需配置 TLS、访问控制、监控与异机备份。
|
||||||
|
|
||||||
## 备份与空实例恢复
|
## 备份与空实例恢复
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ services:
|
|||||||
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?required}
|
AWS_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?required}
|
||||||
ports: !override
|
ports: !override
|
||||||
- "0.0.0.0:${SYNC_PORT:-18080}:8080"
|
- "0.0.0.0:${SYNC_PORT:-18080}:8080"
|
||||||
|
- "[::]:${SYNC_PORT:-18080}:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.3.1-alpha.3}
|
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.4.0-alpha.1}
|
||||||
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.3.1-alpha.3}
|
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.4.0-alpha.1}
|
||||||
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}
|
||||||
@@ -43,8 +43,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.3.1-alpha.3}
|
OPENNEXUS_SYNC_VERSION: ${OPENNEXUS_SYNC_TAG:-0.4.0-alpha.1}
|
||||||
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.3.1-alpha.3}
|
image: opennexus-sync:${OPENNEXUS_SYNC_TAG:-0.4.0-alpha.1}
|
||||||
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "opennexus-sync-console",
|
"name": "opennexus-sync-console",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.1-alpha.3",
|
"version": "0.4.0-alpha.1",
|
||||||
"packageManager": "pnpm@10.28.0",
|
"packageManager": "pnpm@10.28.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "notesagent-sync"
|
name = "notesagent-sync"
|
||||||
version = "0.3.1a3"
|
version = "0.4.0a1"
|
||||||
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",
|
||||||
|
|||||||
Generated
+1
-1
@@ -225,7 +225,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notesagent-sync"
|
name = "notesagent-sync"
|
||||||
version = "0.3.1a3"
|
version = "0.4.0a1"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
|
|||||||
Reference in New Issue
Block a user