chore: checkpoint ai search comments and i18n foundation
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,7 +5,10 @@
|
|||||||
frontend/.astro/
|
frontend/.astro/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
|
mcp-server/node_modules/
|
||||||
|
|
||||||
backend/target/
|
backend/target/
|
||||||
backend/.loco-start.err.log
|
backend/.loco-start.err.log
|
||||||
backend/.loco-start.out.log
|
backend/.loco-start.out.log
|
||||||
|
backend/backend-start.log
|
||||||
|
backend/storage/ai_embedding_models/
|
||||||
|
|||||||
40
README.md
40
README.md
@@ -8,6 +8,7 @@ Monorepo for the Termi blog system.
|
|||||||
.
|
.
|
||||||
├─ frontend/ # Astro blog frontend
|
├─ frontend/ # Astro blog frontend
|
||||||
├─ backend/ # Loco.rs backend and admin
|
├─ backend/ # Loco.rs backend and admin
|
||||||
|
├─ mcp-server/ # Streamable HTTP MCP server for articles/categories/tags
|
||||||
├─ .codex/ # Codex workspace config
|
├─ .codex/ # Codex workspace config
|
||||||
└─ .vscode/ # Editor workspace config
|
└─ .vscode/ # Editor workspace config
|
||||||
```
|
```
|
||||||
@@ -22,6 +23,12 @@ From the repository root:
|
|||||||
.\dev.ps1
|
.\dev.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Frontend + backend + MCP:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\dev.ps1 -WithMcp
|
||||||
|
```
|
||||||
|
|
||||||
Only frontend:
|
Only frontend:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -34,11 +41,18 @@ Only backend:
|
|||||||
.\dev.ps1 -BackendOnly
|
.\dev.ps1 -BackendOnly
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Only MCP:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\dev.ps1 -McpOnly
|
||||||
|
```
|
||||||
|
|
||||||
Direct scripts:
|
Direct scripts:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\start-frontend.ps1
|
.\start-frontend.ps1
|
||||||
.\start-backend.ps1
|
.\start-backend.ps1
|
||||||
|
.\start-mcp.ps1
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
@@ -57,6 +71,32 @@ $env:DATABASE_URL="postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-ap
|
|||||||
cargo loco start 2>&1
|
cargo loco start 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### MCP Server
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\start-mcp.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Default MCP endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5151/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Default local development API key:
|
||||||
|
|
||||||
|
```text
|
||||||
|
termi-mcp-local-dev-key
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP server wraps real backend APIs for:
|
||||||
|
|
||||||
|
- Listing, reading, creating, updating, and deleting Markdown posts
|
||||||
|
- Listing, creating, updating, and deleting categories
|
||||||
|
- Listing, creating, updating, and deleting tags
|
||||||
|
- Reading and updating public site settings
|
||||||
|
- Rebuilding the AI index
|
||||||
|
|
||||||
## Repo Name
|
## Repo Name
|
||||||
|
|
||||||
Recommended repository name: `termi-blog`
|
Recommended repository name: `termi-blog`
|
||||||
|
|||||||
1438
backend/Cargo.lock
generated
1438
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,9 @@ unic-langid = { version = "0.9" }
|
|||||||
# /view engine
|
# /view engine
|
||||||
axum-extra = { version = "0.10", features = ["form"] }
|
axum-extra = { version = "0.10", features = ["form"] }
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||||
|
fastembed = "5.1"
|
||||||
|
async-stream = "0.3"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "termi_api-cli"
|
name = "termi_api-cli"
|
||||||
|
|||||||
@@ -47,7 +47,8 @@
|
|||||||
|
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
textarea {
|
textarea,
|
||||||
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +456,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field input,
|
.field input,
|
||||||
.field textarea {
|
.field textarea,
|
||||||
|
.field select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -676,6 +678,27 @@
|
|||||||
|
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function adminDelete(url, successMessage) {
|
||||||
|
const confirmed = confirm("确认删除这条记录吗?此操作无法撤销。");
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text() || "request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successMessage) {
|
||||||
|
alert(successMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% block page_scripts %}{% endblock %}
|
{% block page_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,11 +1,74 @@
|
|||||||
{% extends "admin/base.html" %}
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
{% block main_content %}
|
{% block main_content %}
|
||||||
|
<section class="form-panel">
|
||||||
|
<div class="table-head">
|
||||||
|
<div>
|
||||||
|
<h2>评论筛选</h2>
|
||||||
|
<div class="table-note">按 scope、审核状态、文章 slug 或关键词快速定位评论,尤其适合处理段落评论和垃圾留言。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="/admin/comments" class="inline-form compact">
|
||||||
|
<div class="compact-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="scope">评论类型</label>
|
||||||
|
<select id="scope" name="scope">
|
||||||
|
<option value="" {% if filters.scope == "" %}selected{% endif %}>全部</option>
|
||||||
|
<option value="article" {% if filters.scope == "article" %}selected{% endif %}>全文评论</option>
|
||||||
|
<option value="paragraph" {% if filters.scope == "paragraph" %}selected{% endif %}>段落评论</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="approved">审核状态</label>
|
||||||
|
<select id="approved" name="approved">
|
||||||
|
<option value="" {% if filters.approved == "" %}selected{% endif %}>全部</option>
|
||||||
|
<option value="true" {% if filters.approved == "true" %}selected{% endif %}>已审核</option>
|
||||||
|
<option value="false" {% if filters.approved == "false" %}selected{% endif %}>待审核</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="post_slug">文章</label>
|
||||||
|
<select id="post_slug" name="post_slug">
|
||||||
|
<option value="" {% if filters.post_slug == "" %}selected{% endif %}>全部文章</option>
|
||||||
|
{% for slug in post_options %}
|
||||||
|
<option value="{{ slug }}" {% if filters.post_slug == slug %}selected{% endif %}>{{ slug }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="q">关键词</label>
|
||||||
|
<input
|
||||||
|
id="q"
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
value="{{ filters.q }}"
|
||||||
|
placeholder="作者 / 内容 / 段落 key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="compact-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">应用筛选</button>
|
||||||
|
<a href="/admin/comments" class="btn btn-ghost">清空</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="inline-links" style="margin-top: 14px;">
|
||||||
|
{% for stat in stats %}
|
||||||
|
<span class="chip">{{ stat.label }} · {{ stat.value }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="table-panel">
|
<section class="table-panel">
|
||||||
<div class="table-head">
|
<div class="table-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>评论队列</h2>
|
<h2>评论队列</h2>
|
||||||
<div class="table-note">处理前台真实评论,并能一键跳到对应文章页核对展示。</div>
|
<div class="table-note">处理前台真实评论,并能一键跳到对应文章或段落核对上下文。</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -16,7 +79,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>作者 / 文章</th>
|
<th>作者 / 文章</th>
|
||||||
<th>内容</th>
|
<th>内容与上下文</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>时间</th>
|
<th>时间</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
@@ -30,12 +93,32 @@
|
|||||||
<div class="item-title">
|
<div class="item-title">
|
||||||
<strong>{{ row.author }}</strong>
|
<strong>{{ row.author }}</strong>
|
||||||
<span class="item-meta">{{ row.post_slug }}</span>
|
<span class="item-meta">{{ row.post_slug }}</span>
|
||||||
|
{% if row.scope == "paragraph" %}
|
||||||
|
<span class="badge badge-warning">{{ row.scope_label }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge">{{ row.scope_label }}</span>
|
||||||
|
{% endif %}
|
||||||
{% if row.frontend_url %}
|
{% if row.frontend_url %}
|
||||||
<a href="{{ row.frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">跳到前台文章</a>
|
<a href="{{ row.frontend_url }}" class="inline-link" target="_blank" rel="noreferrer noopener">
|
||||||
|
{% if row.scope == "paragraph" %}跳到前台段落{% else %}跳到前台文章{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="item-title">
|
||||||
|
<strong>{{ row.content }}</strong>
|
||||||
|
{% if row.reply_target != "-" %}
|
||||||
|
<span class="item-meta">回复目标:{{ row.reply_target }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if row.scope == "paragraph" and row.paragraph_excerpt != "-" %}
|
||||||
|
<span class="item-meta">段落上下文:{{ row.paragraph_excerpt }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if row.scope == "paragraph" and row.paragraph_key != "-" %}
|
||||||
|
<span class="item-meta">段落 key:{{ row.paragraph_key }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ row.content }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if row.approved %}
|
{% if row.approved %}
|
||||||
<span class="badge badge-success">已审核</span>
|
<span class="badge badge-success">已审核</span>
|
||||||
@@ -48,6 +131,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"approved": true}, "评论状态已更新")'>通过</button>
|
<button class="btn btn-success" onclick='adminPatch("{{ row.api_url }}", {"approved": true}, "评论状态已更新")'>通过</button>
|
||||||
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"approved": false}, "评论状态已更新")'>待审</button>
|
<button class="btn btn-warning" onclick='adminPatch("{{ row.api_url }}", {"approved": false}, "评论状态已更新")'>待审</button>
|
||||||
|
<button class="btn btn-danger" onclick='adminDelete("{{ row.api_url }}", "评论已删除")'>删除</button>
|
||||||
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
<a href="{{ row.api_url }}" class="btn btn-ghost" target="_blank" rel="noreferrer noopener">API</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -57,7 +141,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty">暂无评论数据。</div>
|
<div class="empty">当前筛选条件下暂无评论数据。</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="table-head">
|
<div class="table-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>站点资料</h2>
|
<h2>站点资料</h2>
|
||||||
<div class="table-note">保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。</div>
|
<div class="table-note">保存后首页、关于页、页脚和友链页中的本站信息会直接读取这里的配置。AI 问答也在这里统一开启和配置。</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -74,11 +74,54 @@
|
|||||||
<label>技术栈(每行一个)</label>
|
<label>技术栈(每行一个)</label>
|
||||||
<textarea name="tech_stack">{{ form.tech_stack }}</textarea>
|
<textarea name="tech_stack">{{ form.tech_stack }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field field-wide" style="border-top: 1px solid rgba(148, 163, 184, 0.18); padding-top: 18px; margin-top: 10px;">
|
||||||
|
<label style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<input type="checkbox" name="ai_enabled" {% if form.ai_enabled %}checked{% endif %}>
|
||||||
|
<span>启用前台 AI 问答</span>
|
||||||
|
</label>
|
||||||
|
<div class="field-hint">关闭后,前台导航不会显示 AI 页面,公开接口也不会对外提供回答。Embedding 已改为后端本地生成,并使用 PostgreSQL 的 pgvector 存储与检索。</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>聊天 Provider</label>
|
||||||
|
<input name="ai_provider" value="{{ form.ai_provider }}" placeholder="newapi">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>聊天 API Base</label>
|
||||||
|
<input name="ai_api_base" value="{{ form.ai_api_base }}" placeholder="http://localhost:8317/v1">
|
||||||
|
</div>
|
||||||
<div class="field field-wide">
|
<div class="field field-wide">
|
||||||
|
<label>聊天 API Key</label>
|
||||||
|
<input name="ai_api_key" value="{{ form.ai_api_key }}" placeholder="your-api-key-1">
|
||||||
|
<div class="field-hint">这里只保存在后端数据库里,前台公开接口不会返回这个字段。当前默认接入本地 NewAPI 网关,未配置时前台仍可做本地检索,但不会生成完整聊天回答。</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>聊天模型</label>
|
||||||
|
<input name="ai_chat_model" value="{{ form.ai_chat_model }}" placeholder="gpt-5.4">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>本地 Embedding</label>
|
||||||
|
<input value="{{ form.ai_local_embedding }}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Top K</label>
|
||||||
|
<input type="number" min="1" max="12" name="ai_top_k" value="{{ form.ai_top_k }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Chunk Size</label>
|
||||||
|
<input type="number" min="400" max="4000" step="50" name="ai_chunk_size" value="{{ form.ai_chunk_size }}">
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<label>系统提示词</label>
|
||||||
|
<textarea name="ai_system_prompt">{{ form.ai_system_prompt }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="field field-wide">
|
||||||
|
<div class="table-note">AI 索引状态:已索引 {{ form.ai_chunks_count }} 个片段,最近建立时间 {{ form.ai_last_indexed_at }}。</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn btn-primary">保存设置</button>
|
<button type="submit" class="btn btn-primary">保存设置</button>
|
||||||
|
<button type="button" id="reindex-btn" class="btn">重建 AI 索引</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-hint" style="margin-top: 10px;">保存后可直接点击顶部“预览首页 / 预览关于页 / 预览友链页”确认前台展示。</div>
|
<div class="field-hint" style="margin-top: 10px;">文章内容变化后建议手动重建一次 AI 索引。本地 embedding 使用后端内置 `fastembed` 生成,向量会写入 PostgreSQL 的 `pgvector` 列,并通过 HNSW 索引做相似度检索;聊天回答默认走 `newapi -> /responses -> gpt-5.4`。</div>
|
||||||
<div id="notice" class="notice"></div>
|
<div id="notice" class="notice"></div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -89,12 +132,18 @@
|
|||||||
<script>
|
<script>
|
||||||
const form = document.getElementById("site-settings-form");
|
const form = document.getElementById("site-settings-form");
|
||||||
const notice = document.getElementById("notice");
|
const notice = document.getElementById("notice");
|
||||||
|
const reindexBtn = document.getElementById("reindex-btn");
|
||||||
|
|
||||||
function showNotice(message, kind) {
|
function showNotice(message, kind) {
|
||||||
notice.textContent = message;
|
notice.textContent = message;
|
||||||
notice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
notice.className = "notice show " + (kind === "success" ? "notice-success" : "notice-error");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numericOrNull(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
form?.addEventListener("submit", async (event) => {
|
form?.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -118,7 +167,15 @@
|
|||||||
techStack: String(data.get("tech_stack") || "")
|
techStack: String(data.get("tech_stack") || "")
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean),
|
||||||
|
aiEnabled: data.get("ai_enabled") === "on",
|
||||||
|
aiProvider: data.get("ai_provider"),
|
||||||
|
aiApiBase: data.get("ai_api_base"),
|
||||||
|
aiApiKey: data.get("ai_api_key"),
|
||||||
|
aiChatModel: data.get("ai_chat_model"),
|
||||||
|
aiTopK: numericOrNull(data.get("ai_top_k")),
|
||||||
|
aiChunkSize: numericOrNull(data.get("ai_chunk_size")),
|
||||||
|
aiSystemPrompt: data.get("ai_system_prompt")
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -134,10 +191,34 @@
|
|||||||
throw new Error(await response.text() || "save failed");
|
throw new Error(await response.text() || "save failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
showNotice("站点信息已保存。", "success");
|
showNotice("站点信息与 AI 配置已保存。", "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showNotice("保存失败:" + (error?.message || "unknown error"), "error");
|
showNotice("保存失败:" + (error?.message || "unknown error"), "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
reindexBtn?.addEventListener("click", async () => {
|
||||||
|
reindexBtn.disabled = true;
|
||||||
|
reindexBtn.textContent = "正在重建...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/ai/reindex", {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text() || "reindex failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
showNotice(`AI 索引已重建,当前共有 ${data.indexed_chunks} 个片段。`, "success");
|
||||||
|
window.setTimeout(() => window.location.reload(), 900);
|
||||||
|
} catch (error) {
|
||||||
|
showNotice("重建失败:" + (error?.message || "unknown error"), "error");
|
||||||
|
} finally {
|
||||||
|
reindexBtn.disabled = false;
|
||||||
|
reindexBtn.textContent = "重建 AI 索引";
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ mod m20260328_000002_create_site_settings;
|
|||||||
mod m20260328_000003_add_site_url_to_site_settings;
|
mod m20260328_000003_add_site_url_to_site_settings;
|
||||||
mod m20260328_000004_add_posts_search_index;
|
mod m20260328_000004_add_posts_search_index;
|
||||||
mod m20260328_000005_categories;
|
mod m20260328_000005_categories;
|
||||||
|
mod m20260328_000006_add_ai_to_site_settings;
|
||||||
|
mod m20260328_000007_create_ai_chunks;
|
||||||
|
mod m20260328_000008_enable_pgvector_for_ai_chunks;
|
||||||
|
mod m20260328_000009_add_paragraph_comments;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -30,6 +34,10 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260328_000003_add_site_url_to_site_settings::Migration),
|
Box::new(m20260328_000003_add_site_url_to_site_settings::Migration),
|
||||||
Box::new(m20260328_000004_add_posts_search_index::Migration),
|
Box::new(m20260328_000004_add_posts_search_index::Migration),
|
||||||
Box::new(m20260328_000005_categories::Migration),
|
Box::new(m20260328_000005_categories::Migration),
|
||||||
|
Box::new(m20260328_000006_add_ai_to_site_settings::Migration),
|
||||||
|
Box::new(m20260328_000007_create_ai_chunks::Migration),
|
||||||
|
Box::new(m20260328_000008_enable_pgvector_for_ai_chunks::Migration),
|
||||||
|
Box::new(m20260328_000009_add_paragraph_comments::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
if !manager.has_column("site_settings", "ai_enabled").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("ai_enabled"))
|
||||||
|
.boolean()
|
||||||
|
.null()
|
||||||
|
.default(false),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column("site_settings", "ai_provider").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(ColumnDef::new(Alias::new("ai_provider")).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column("site_settings", "ai_api_base").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(ColumnDef::new(Alias::new("ai_api_base")).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column("site_settings", "ai_api_key").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(ColumnDef::new(Alias::new("ai_api_key")).text().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column("site_settings", "ai_chat_model").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(ColumnDef::new(Alias::new("ai_chat_model")).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "ai_embedding_model")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("ai_embedding_model"))
|
||||||
|
.string()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "ai_system_prompt")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(ColumnDef::new(Alias::new("ai_system_prompt")).text().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column("site_settings", "ai_top_k").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(ColumnDef::new(Alias::new("ai_top_k")).integer().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column("site_settings", "ai_chunk_size").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(ColumnDef::new(Alias::new("ai_chunk_size")).integer().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager
|
||||||
|
.has_column("site_settings", "ai_last_indexed_at")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table)
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("ai_last_indexed_at"))
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new("site_settings");
|
||||||
|
|
||||||
|
for column in [
|
||||||
|
"ai_last_indexed_at",
|
||||||
|
"ai_chunk_size",
|
||||||
|
"ai_top_k",
|
||||||
|
"ai_system_prompt",
|
||||||
|
"ai_embedding_model",
|
||||||
|
"ai_chat_model",
|
||||||
|
"ai_api_key",
|
||||||
|
"ai_api_base",
|
||||||
|
"ai_provider",
|
||||||
|
"ai_enabled",
|
||||||
|
] {
|
||||||
|
if manager.has_column("site_settings", column).await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.drop_column(Alias::new(column))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/migration/src/m20260328_000007_create_ai_chunks.rs
Normal file
33
backend/migration/src/m20260328_000007_create_ai_chunks.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
create_table(
|
||||||
|
manager,
|
||||||
|
"ai_chunks",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("source_slug", ColType::String),
|
||||||
|
("source_title", ColType::StringNull),
|
||||||
|
("source_path", ColType::StringNull),
|
||||||
|
("source_type", ColType::String),
|
||||||
|
("chunk_index", ColType::Integer),
|
||||||
|
("content", ColType::Text),
|
||||||
|
("content_preview", ColType::StringNull),
|
||||||
|
("embedding", ColType::JsonBinaryNull),
|
||||||
|
("word_count", ColType::IntegerNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(manager, "ai_chunks").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
const AI_CHUNKS_TABLE: &str = "ai_chunks";
|
||||||
|
const VECTOR_INDEX_NAME: &str = "idx_ai_chunks_embedding_hnsw";
|
||||||
|
const EMBEDDING_DIMENSION: i32 = 384;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if manager.has_column(AI_CHUNKS_TABLE, "embedding").await? {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("ALTER TABLE ai_chunks DROP COLUMN embedding")
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column(AI_CHUNKS_TABLE, "embedding").await? {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(&format!(
|
||||||
|
"ALTER TABLE ai_chunks ADD COLUMN embedding vector({EMBEDDING_DIMENSION})"
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("TRUNCATE TABLE ai_chunks RESTART IDENTITY")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(&format!(
|
||||||
|
"CREATE INDEX IF NOT EXISTS {VECTOR_INDEX_NAME} ON ai_chunks USING hnsw (embedding vector_cosine_ops)"
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(&format!("DROP INDEX IF EXISTS {VECTOR_INDEX_NAME}"))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if manager.has_column(AI_CHUNKS_TABLE, "embedding").await? {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("ALTER TABLE ai_chunks DROP COLUMN embedding")
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("ALTER TABLE ai_chunks ADD COLUMN embedding jsonb")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
109
backend/migration/src/m20260328_000009_add_paragraph_comments.rs
Normal file
109
backend/migration/src/m20260328_000009_add_paragraph_comments.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
const TABLE: &str = "comments";
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let table = Alias::new(TABLE);
|
||||||
|
|
||||||
|
if !manager.has_column(TABLE, "scope").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("scope"))
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.default("article"),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column(TABLE, "paragraph_key").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(ColumnDef::new(Alias::new("paragraph_key")).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column(TABLE, "paragraph_excerpt").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(ColumnDef::new(Alias::new("paragraph_excerpt")).string().null())
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !manager.has_column(TABLE, "reply_to_comment_id").await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(table.clone())
|
||||||
|
.add_column(
|
||||||
|
ColumnDef::new(Alias::new("reply_to_comment_id"))
|
||||||
|
.integer()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
"UPDATE comments SET scope = 'article' WHERE scope IS NULL OR trim(scope) = ''",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_comments_post_scope_paragraph ON comments (post_slug, scope, paragraph_key)",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.get_connection()
|
||||||
|
.execute_unprepared("DROP INDEX IF EXISTS idx_comments_post_scope_paragraph")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for column in [
|
||||||
|
"reply_to_comment_id",
|
||||||
|
"paragraph_excerpt",
|
||||||
|
"paragraph_key",
|
||||||
|
"scope",
|
||||||
|
] {
|
||||||
|
if manager.has_column(TABLE, column).await? {
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(Alias::new(TABLE))
|
||||||
|
.drop_column(Alias::new(column))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ use tower_http::cors::{Any, CorsLayer};
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers, initializers,
|
controllers, initializers,
|
||||||
models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags, users},
|
models::_entities::{
|
||||||
|
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users,
|
||||||
|
},
|
||||||
tasks,
|
tasks,
|
||||||
workers::downloader::DownloadWorker,
|
workers::downloader::DownloadWorker,
|
||||||
};
|
};
|
||||||
@@ -69,12 +71,19 @@ impl Hooks for App {
|
|||||||
.add_route(controllers::post::routes())
|
.add_route(controllers::post::routes())
|
||||||
.add_route(controllers::search::routes())
|
.add_route(controllers::search::routes())
|
||||||
.add_route(controllers::site_settings::routes())
|
.add_route(controllers::site_settings::routes())
|
||||||
|
.add_route(controllers::ai::routes())
|
||||||
.add_route(controllers::auth::routes())
|
.add_route(controllers::auth::routes())
|
||||||
}
|
}
|
||||||
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE])
|
.allow_methods([
|
||||||
|
Method::GET,
|
||||||
|
Method::POST,
|
||||||
|
Method::PUT,
|
||||||
|
Method::PATCH,
|
||||||
|
Method::DELETE,
|
||||||
|
])
|
||||||
.allow_headers(Any);
|
.allow_headers(Any);
|
||||||
|
|
||||||
Ok(router.layer(cors))
|
Ok(router.layer(cors))
|
||||||
@@ -88,10 +97,6 @@ impl Hooks for App {
|
|||||||
fn register_tasks(tasks: &mut Tasks) {
|
fn register_tasks(tasks: &mut Tasks) {
|
||||||
// tasks-inject (do not remove)
|
// tasks-inject (do not remove)
|
||||||
}
|
}
|
||||||
async fn truncate(ctx: &AppContext) -> Result<()> {
|
|
||||||
truncate_table(&ctx.db, users::Entity).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||||
// Seed users - use loco's default seed which handles duplicates
|
// Seed users - use loco's default seed which handles duplicates
|
||||||
let users_file = base.join("users.yaml");
|
let users_file = base.join("users.yaml");
|
||||||
@@ -275,44 +280,59 @@ impl Hooks for App {
|
|||||||
let item = site_settings::ActiveModel {
|
let item = site_settings::ActiveModel {
|
||||||
id: Set(settings["id"].as_i64().unwrap_or(1) as i32),
|
id: Set(settings["id"].as_i64().unwrap_or(1) as i32),
|
||||||
site_name: Set(settings["site_name"].as_str().map(ToString::to_string)),
|
site_name: Set(settings["site_name"].as_str().map(ToString::to_string)),
|
||||||
site_short_name: Set(
|
site_short_name: Set(settings["site_short_name"]
|
||||||
settings["site_short_name"].as_str().map(ToString::to_string),
|
.as_str()
|
||||||
),
|
.map(ToString::to_string)),
|
||||||
site_url: Set(settings["site_url"].as_str().map(ToString::to_string)),
|
site_url: Set(settings["site_url"].as_str().map(ToString::to_string)),
|
||||||
site_title: Set(settings["site_title"].as_str().map(ToString::to_string)),
|
site_title: Set(settings["site_title"].as_str().map(ToString::to_string)),
|
||||||
site_description: Set(
|
site_description: Set(settings["site_description"]
|
||||||
settings["site_description"].as_str().map(ToString::to_string),
|
.as_str()
|
||||||
),
|
.map(ToString::to_string)),
|
||||||
hero_title: Set(settings["hero_title"].as_str().map(ToString::to_string)),
|
hero_title: Set(settings["hero_title"].as_str().map(ToString::to_string)),
|
||||||
hero_subtitle: Set(
|
hero_subtitle: Set(settings["hero_subtitle"]
|
||||||
settings["hero_subtitle"].as_str().map(ToString::to_string),
|
.as_str()
|
||||||
),
|
.map(ToString::to_string)),
|
||||||
owner_name: Set(settings["owner_name"].as_str().map(ToString::to_string)),
|
owner_name: Set(settings["owner_name"].as_str().map(ToString::to_string)),
|
||||||
owner_title: Set(
|
owner_title: Set(settings["owner_title"].as_str().map(ToString::to_string)),
|
||||||
settings["owner_title"].as_str().map(ToString::to_string),
|
|
||||||
),
|
|
||||||
owner_bio: Set(settings["owner_bio"].as_str().map(ToString::to_string)),
|
owner_bio: Set(settings["owner_bio"].as_str().map(ToString::to_string)),
|
||||||
owner_avatar_url: Set(
|
owner_avatar_url: Set(settings["owner_avatar_url"].as_str().and_then(
|
||||||
settings["owner_avatar_url"].as_str().and_then(|value| {
|
|value| {
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(trimmed.to_string())
|
Some(trimmed.to_string())
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
),
|
)),
|
||||||
social_github: Set(
|
social_github: Set(settings["social_github"]
|
||||||
settings["social_github"].as_str().map(ToString::to_string),
|
.as_str()
|
||||||
),
|
.map(ToString::to_string)),
|
||||||
social_twitter: Set(
|
social_twitter: Set(settings["social_twitter"]
|
||||||
settings["social_twitter"].as_str().map(ToString::to_string),
|
.as_str()
|
||||||
),
|
.map(ToString::to_string)),
|
||||||
social_email: Set(
|
social_email: Set(settings["social_email"]
|
||||||
settings["social_email"].as_str().map(ToString::to_string),
|
.as_str()
|
||||||
),
|
.map(ToString::to_string)),
|
||||||
location: Set(settings["location"].as_str().map(ToString::to_string)),
|
location: Set(settings["location"].as_str().map(ToString::to_string)),
|
||||||
tech_stack: Set(tech_stack),
|
tech_stack: Set(tech_stack),
|
||||||
|
ai_enabled: Set(settings["ai_enabled"].as_bool()),
|
||||||
|
ai_provider: Set(settings["ai_provider"].as_str().map(ToString::to_string)),
|
||||||
|
ai_api_base: Set(settings["ai_api_base"].as_str().map(ToString::to_string)),
|
||||||
|
ai_api_key: Set(settings["ai_api_key"].as_str().map(ToString::to_string)),
|
||||||
|
ai_chat_model: Set(settings["ai_chat_model"]
|
||||||
|
.as_str()
|
||||||
|
.map(ToString::to_string)),
|
||||||
|
ai_embedding_model: Set(settings["ai_embedding_model"]
|
||||||
|
.as_str()
|
||||||
|
.map(ToString::to_string)),
|
||||||
|
ai_system_prompt: Set(settings["ai_system_prompt"]
|
||||||
|
.as_str()
|
||||||
|
.map(ToString::to_string)),
|
||||||
|
ai_top_k: Set(settings["ai_top_k"].as_i64().map(|value| value as i32)),
|
||||||
|
ai_chunk_size: Set(settings["ai_chunk_size"]
|
||||||
|
.as_i64()
|
||||||
|
.map(|value| value as i32)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let _ = item.insert(&ctx.db).await;
|
let _ = item.insert(&ctx.db).await;
|
||||||
@@ -365,4 +385,10 @@ impl Hooks for App {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn truncate(ctx: &AppContext) -> Result<()> {
|
||||||
|
truncate_table(&ctx.db, ai_chunks::Entity).await?;
|
||||||
|
truncate_table(&ctx.db, users::Entity).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
use axum::{extract::{Multipart, Query, State}, Form};
|
use axum::{
|
||||||
|
extract::{Multipart, Query, State},
|
||||||
|
Form,
|
||||||
|
};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, Set};
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
||||||
|
QueryOrder, Set,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use crate::models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags};
|
use crate::models::_entities::{
|
||||||
use crate::services::content;
|
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags,
|
||||||
|
};
|
||||||
|
use crate::services::{ai, content};
|
||||||
|
|
||||||
static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false);
|
static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false);
|
||||||
const FRONTEND_BASE_URL: &str = "http://localhost:4321";
|
const FRONTEND_BASE_URL: &str = "http://localhost:4321";
|
||||||
@@ -23,6 +31,14 @@ pub struct LoginQuery {
|
|||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Deserialize)]
|
||||||
|
pub struct CommentAdminQuery {
|
||||||
|
scope: Option<String>,
|
||||||
|
approved: Option<String>,
|
||||||
|
post_slug: Option<String>,
|
||||||
|
q: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct HeaderAction {
|
struct HeaderAction {
|
||||||
label: String,
|
label: String,
|
||||||
@@ -89,12 +105,31 @@ struct CommentRow {
|
|||||||
author: String,
|
author: String,
|
||||||
post_slug: String,
|
post_slug: String,
|
||||||
content: String,
|
content: String,
|
||||||
|
scope: String,
|
||||||
|
scope_label: String,
|
||||||
|
paragraph_excerpt: String,
|
||||||
|
paragraph_key: String,
|
||||||
|
reply_target: String,
|
||||||
approved: bool,
|
approved: bool,
|
||||||
created_at: String,
|
created_at: String,
|
||||||
frontend_url: Option<String>,
|
frontend_url: Option<String>,
|
||||||
api_url: String,
|
api_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CommentFilterState {
|
||||||
|
scope: String,
|
||||||
|
approved: String,
|
||||||
|
post_slug: String,
|
||||||
|
q: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CommentFilterStat {
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct TagRow {
|
struct TagRow {
|
||||||
id: i32,
|
id: i32,
|
||||||
@@ -174,13 +209,9 @@ fn url_encode(value: &str) -> String {
|
|||||||
let mut encoded = String::new();
|
let mut encoded = String::new();
|
||||||
for byte in value.as_bytes() {
|
for byte in value.as_bytes() {
|
||||||
match byte {
|
match byte {
|
||||||
b'A'..=b'Z'
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||||
| b'a'..=b'z'
|
encoded.push(*byte as char)
|
||||||
| b'0'..=b'9'
|
}
|
||||||
| b'-'
|
|
||||||
| b'_'
|
|
||||||
| b'.'
|
|
||||||
| b'~' => encoded.push(*byte as char),
|
|
||||||
b' ' => encoded.push_str("%20"),
|
b' ' => encoded.push_str("%20"),
|
||||||
_ => encoded.push_str(&format!("%{byte:02X}")),
|
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||||
}
|
}
|
||||||
@@ -286,6 +317,60 @@ fn link_status_text(status: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn comment_scope_label(scope: &str) -> &'static str {
|
||||||
|
match scope {
|
||||||
|
"paragraph" => "段落评论",
|
||||||
|
_ => "全文评论",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comment_frontend_url(comment: &comments::Model) -> Option<String> {
|
||||||
|
let slug = comment
|
||||||
|
.post_slug
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())?;
|
||||||
|
|
||||||
|
let mut url = frontend_path(&format!("/articles/{slug}"));
|
||||||
|
if comment.scope == "paragraph" {
|
||||||
|
if let Some(paragraph_key) = comment
|
||||||
|
.paragraph_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
url.push('#');
|
||||||
|
url.push_str("paragraph-");
|
||||||
|
url.push_str(paragraph_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_filter_value(value: Option<&str>) -> String {
|
||||||
|
value.unwrap_or_default().trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn comment_matches_query(comment: &comments::Model, query: &str) -> bool {
|
||||||
|
if query.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = query.to_lowercase();
|
||||||
|
let fields = [
|
||||||
|
comment.author.as_deref().unwrap_or_default(),
|
||||||
|
comment.post_slug.as_deref().unwrap_or_default(),
|
||||||
|
comment.content.as_deref().unwrap_or_default(),
|
||||||
|
comment.paragraph_excerpt.as_deref().unwrap_or_default(),
|
||||||
|
comment.paragraph_key.as_deref().unwrap_or_default(),
|
||||||
|
];
|
||||||
|
|
||||||
|
fields
|
||||||
|
.iter()
|
||||||
|
.any(|value| value.to_lowercase().contains(&query))
|
||||||
|
}
|
||||||
|
|
||||||
fn page_context(title: &str, description: &str, active_nav: &str) -> Map<String, Value> {
|
fn page_context(title: &str, description: &str, active_nav: &str) -> Map<String, Value> {
|
||||||
let mut context = Map::new();
|
let mut context = Map::new();
|
||||||
context.insert("page_title".into(), json!(title));
|
context.insert("page_title".into(), json!(title));
|
||||||
@@ -312,7 +397,7 @@ fn render_admin(
|
|||||||
format::view(&view_engine.0, template, Value::Object(context))
|
format::view(&view_engine.0, template, Value::Object(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_auth() -> Result<()> {
|
pub(crate) fn check_auth() -> Result<()> {
|
||||||
if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) {
|
if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) {
|
||||||
return Err(Error::Unauthorized("Not logged in".to_string()));
|
return Err(Error::Unauthorized("Not logged in".to_string()));
|
||||||
}
|
}
|
||||||
@@ -470,16 +555,26 @@ pub async fn index(
|
|||||||
];
|
];
|
||||||
|
|
||||||
let profile = SiteProfile {
|
let profile = SiteProfile {
|
||||||
site_name: non_empty(site.as_ref().and_then(|item| item.site_name.as_deref()), "未配置站点"),
|
site_name: non_empty(
|
||||||
|
site.as_ref().and_then(|item| item.site_name.as_deref()),
|
||||||
|
"未配置站点",
|
||||||
|
),
|
||||||
site_description: non_empty(
|
site_description: non_empty(
|
||||||
site.as_ref()
|
site.as_ref()
|
||||||
.and_then(|item| item.site_description.as_deref()),
|
.and_then(|item| item.site_description.as_deref()),
|
||||||
"站点简介尚未设置",
|
"站点简介尚未设置",
|
||||||
),
|
),
|
||||||
site_url: non_empty(site.as_ref().and_then(|item| item.site_url.as_deref()), "未配置站点链接"),
|
site_url: non_empty(
|
||||||
|
site.as_ref().and_then(|item| item.site_url.as_deref()),
|
||||||
|
"未配置站点链接",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut context = page_context("后台总览", "前后台共用同一份数据,这里可以快速处理内容和跳转前台。", "dashboard");
|
let mut context = page_context(
|
||||||
|
"后台总览",
|
||||||
|
"前后台共用同一份数据,这里可以快速处理内容和跳转前台。",
|
||||||
|
"dashboard",
|
||||||
|
);
|
||||||
context.insert(
|
context.insert(
|
||||||
"header_actions".into(),
|
"header_actions".into(),
|
||||||
json!([
|
json!([
|
||||||
@@ -523,7 +618,11 @@ pub async fn posts_admin(
|
|||||||
file_path: file_path_by_slug
|
file_path: file_path_by_slug
|
||||||
.get(&post.slug)
|
.get(&post.slug)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| content::markdown_post_path(&post.slug).to_string_lossy().to_string()),
|
.unwrap_or_else(|| {
|
||||||
|
content::markdown_post_path(&post.slug)
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
}),
|
||||||
created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||||
category_name: category_name.clone(),
|
category_name: category_name.clone(),
|
||||||
category_frontend_url: frontend_query_url("/articles", "category", &category_name),
|
category_frontend_url: frontend_query_url("/articles", "category", &category_name),
|
||||||
@@ -535,7 +634,11 @@ pub async fn posts_admin(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut context = page_context("文章管理", "核对文章、分类和标签,并可直接跳到前台详情页。", "posts");
|
let mut context = page_context(
|
||||||
|
"文章管理",
|
||||||
|
"核对文章、分类和标签,并可直接跳到前台详情页。",
|
||||||
|
"posts",
|
||||||
|
);
|
||||||
context.insert(
|
context.insert(
|
||||||
"header_actions".into(),
|
"header_actions".into(),
|
||||||
json!([
|
json!([
|
||||||
@@ -596,7 +699,11 @@ pub async fn posts_import(
|
|||||||
|
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
|
|
||||||
while let Some(field) = multipart.next_field().await.map_err(|error| Error::BadRequest(error.to_string()))? {
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(error.to_string()))?
|
||||||
|
{
|
||||||
let file_name = field
|
let file_name = field
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
@@ -642,9 +749,19 @@ pub async fn post_editor(
|
|||||||
"header_actions".into(),
|
"header_actions".into(),
|
||||||
json!([
|
json!([
|
||||||
action("返回文章管理", "/admin/posts".to_string(), "ghost", false),
|
action("返回文章管理", "/admin/posts".to_string(), "ghost", false),
|
||||||
action("前台预览", frontend_path(&format!("/articles/{}", slug)), "primary", true),
|
action(
|
||||||
|
"前台预览",
|
||||||
|
frontend_path(&format!("/articles/{}", slug)),
|
||||||
|
"primary",
|
||||||
|
true
|
||||||
|
),
|
||||||
action("文章 API", format!("/api/posts/slug/{slug}"), "ghost", true),
|
action("文章 API", format!("/api/posts/slug/{slug}"), "ghost", true),
|
||||||
action("Markdown API", format!("/api/posts/slug/{slug}/markdown"), "ghost", true),
|
action(
|
||||||
|
"Markdown API",
|
||||||
|
format!("/api/posts/slug/{slug}/markdown"),
|
||||||
|
"ghost",
|
||||||
|
true
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
context.insert(
|
context.insert(
|
||||||
@@ -662,6 +779,7 @@ pub async fn post_editor(
|
|||||||
|
|
||||||
pub async fn comments_admin(
|
pub async fn comments_admin(
|
||||||
view_engine: ViewEngine<TeraView>,
|
view_engine: ViewEngine<TeraView>,
|
||||||
|
Query(query): Query<CommentAdminQuery>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
check_auth()?;
|
check_auth()?;
|
||||||
@@ -672,8 +790,62 @@ pub async fn comments_admin(
|
|||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let scope_filter = normalized_filter_value(query.scope.as_deref());
|
||||||
|
let approved_filter = normalized_filter_value(query.approved.as_deref());
|
||||||
|
let post_slug_filter = normalized_filter_value(query.post_slug.as_deref());
|
||||||
|
let text_filter = normalized_filter_value(query.q.as_deref());
|
||||||
|
|
||||||
|
let total_count = items.len();
|
||||||
|
let article_count = items.iter().filter(|comment| comment.scope != "paragraph").count();
|
||||||
|
let paragraph_count = items.iter().filter(|comment| comment.scope == "paragraph").count();
|
||||||
|
let pending_count = items
|
||||||
|
.iter()
|
||||||
|
.filter(|comment| !comment.approved.unwrap_or(false))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let author_by_id = items
|
||||||
|
.iter()
|
||||||
|
.map(|comment| {
|
||||||
|
(
|
||||||
|
comment.id,
|
||||||
|
non_empty(comment.author.as_deref(), "匿名"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
|
||||||
|
let post_options = items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|comment| comment.post_slug.as_deref())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|slug| !slug.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let rows = items
|
let rows = items
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|comment| {
|
||||||
|
if !scope_filter.is_empty() && comment.scope != scope_filter {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if approved_filter == "true" && !comment.approved.unwrap_or(false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if approved_filter == "false" && comment.approved.unwrap_or(false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !post_slug_filter.is_empty()
|
||||||
|
&& comment.post_slug.as_deref().unwrap_or_default().trim() != post_slug_filter
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
comment_matches_query(comment, &text_filter)
|
||||||
|
})
|
||||||
.map(|comment| {
|
.map(|comment| {
|
||||||
let post_slug = non_empty(comment.post_slug.as_deref(), "未关联文章");
|
let post_slug = non_empty(comment.post_slug.as_deref(), "未关联文章");
|
||||||
CommentRow {
|
CommentRow {
|
||||||
@@ -681,19 +853,33 @@ pub async fn comments_admin(
|
|||||||
author: non_empty(comment.author.as_deref(), "匿名"),
|
author: non_empty(comment.author.as_deref(), "匿名"),
|
||||||
post_slug: post_slug.clone(),
|
post_slug: post_slug.clone(),
|
||||||
content: non_empty(comment.content.as_deref(), "-"),
|
content: non_empty(comment.content.as_deref(), "-"),
|
||||||
|
scope: comment.scope.clone(),
|
||||||
|
scope_label: comment_scope_label(&comment.scope).to_string(),
|
||||||
|
paragraph_excerpt: non_empty(comment.paragraph_excerpt.as_deref(), "-"),
|
||||||
|
paragraph_key: non_empty(comment.paragraph_key.as_deref(), "-"),
|
||||||
|
reply_target: comment
|
||||||
|
.reply_to_comment_id
|
||||||
|
.map(|reply_id| {
|
||||||
|
let author = author_by_id
|
||||||
|
.get(&reply_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "未知评论".to_string());
|
||||||
|
format!("#{reply_id} · {author}")
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
approved: comment.approved.unwrap_or(false),
|
approved: comment.approved.unwrap_or(false),
|
||||||
created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||||
frontend_url: comment
|
frontend_url: comment_frontend_url(comment),
|
||||||
.post_slug
|
|
||||||
.as_deref()
|
|
||||||
.filter(|slug| !slug.trim().is_empty())
|
|
||||||
.map(|slug| frontend_path(&format!("/articles/{slug}"))),
|
|
||||||
api_url: format!("/api/comments/{}", comment.id),
|
api_url: format!("/api/comments/{}", comment.id),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut context = page_context("评论审核", "前台真实评论会先进入这里,审核通过后再展示到文章页。", "comments");
|
let mut context = page_context(
|
||||||
|
"评论审核",
|
||||||
|
"前台真实评论会先进入这里,审核通过后再展示到文章页。",
|
||||||
|
"comments",
|
||||||
|
);
|
||||||
context.insert(
|
context.insert(
|
||||||
"header_actions".into(),
|
"header_actions".into(),
|
||||||
json!([
|
json!([
|
||||||
@@ -701,6 +887,37 @@ pub async fn comments_admin(
|
|||||||
action("评论 API", "/api/comments".to_string(), "ghost", true),
|
action("评论 API", "/api/comments".to_string(), "ghost", true),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
context.insert(
|
||||||
|
"filters".into(),
|
||||||
|
json!(CommentFilterState {
|
||||||
|
scope: scope_filter,
|
||||||
|
approved: approved_filter,
|
||||||
|
post_slug: post_slug_filter,
|
||||||
|
q: text_filter,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
context.insert("post_options".into(), json!(post_options));
|
||||||
|
context.insert(
|
||||||
|
"stats".into(),
|
||||||
|
json!([
|
||||||
|
CommentFilterStat {
|
||||||
|
label: "全部评论".to_string(),
|
||||||
|
value: total_count.to_string(),
|
||||||
|
},
|
||||||
|
CommentFilterStat {
|
||||||
|
label: "全文评论".to_string(),
|
||||||
|
value: article_count.to_string(),
|
||||||
|
},
|
||||||
|
CommentFilterStat {
|
||||||
|
label: "段落评论".to_string(),
|
||||||
|
value: paragraph_count.to_string(),
|
||||||
|
},
|
||||||
|
CommentFilterStat {
|
||||||
|
label: "待审核".to_string(),
|
||||||
|
value: pending_count.to_string(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
context.insert("rows".into(), json!(rows));
|
context.insert("rows".into(), json!(rows));
|
||||||
|
|
||||||
render_admin(&view_engine, "admin/comments.html", context)
|
render_admin(&view_engine, "admin/comments.html", context)
|
||||||
@@ -742,7 +959,8 @@ pub async fn categories_admin(
|
|||||||
.and_then(|post| post.title.as_deref())
|
.and_then(|post| post.title.as_deref())
|
||||||
.unwrap_or("最近文章")
|
.unwrap_or("最近文章")
|
||||||
.to_string(),
|
.to_string(),
|
||||||
latest_frontend_url: latest.map(|post| frontend_path(&format!("/articles/{}", post.slug))),
|
latest_frontend_url: latest
|
||||||
|
.map(|post| frontend_path(&format!("/articles/{}", post.slug))),
|
||||||
frontend_url: frontend_path("/categories"),
|
frontend_url: frontend_path("/categories"),
|
||||||
articles_url: frontend_query_url("/articles", "category", &name),
|
articles_url: frontend_query_url("/articles", "category", &name),
|
||||||
api_url: format!("/api/categories/{}", category.id),
|
api_url: format!("/api/categories/{}", category.id),
|
||||||
@@ -758,7 +976,11 @@ pub async fn categories_admin(
|
|||||||
.then_with(|| left.name.cmp(&right.name))
|
.then_with(|| left.name.cmp(&right.name))
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut context = page_context("分类管理", "维护分类字典。Markdown 导入文章时,如果分类不存在会自动创建;已存在则复用现有分类。", "categories");
|
let mut context = page_context(
|
||||||
|
"分类管理",
|
||||||
|
"维护分类字典。Markdown 导入文章时,如果分类不存在会自动创建;已存在则复用现有分类。",
|
||||||
|
"categories",
|
||||||
|
);
|
||||||
context.insert(
|
context.insert(
|
||||||
"header_actions".into(),
|
"header_actions".into(),
|
||||||
json!([
|
json!([
|
||||||
@@ -896,7 +1118,11 @@ pub async fn tags_admin(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut context = page_context("标签管理", "维护标签字典。Markdown 导入文章时,如果标签不存在会自动创建;已存在则复用现有标签。", "tags");
|
let mut context = page_context(
|
||||||
|
"标签管理",
|
||||||
|
"维护标签字典。Markdown 导入文章时,如果标签不存在会自动创建;已存在则复用现有标签。",
|
||||||
|
"tags",
|
||||||
|
);
|
||||||
context.insert(
|
context.insert(
|
||||||
"header_actions".into(),
|
"header_actions".into(),
|
||||||
json!([
|
json!([
|
||||||
@@ -1019,7 +1245,11 @@ pub async fn reviews_admin(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut context = page_context("评价管理", "创建和编辑评价内容,前台评价页直接读取数据库里的真实数据。", "reviews");
|
let mut context = page_context(
|
||||||
|
"评价管理",
|
||||||
|
"创建和编辑评价内容,前台评价页直接读取数据库里的真实数据。",
|
||||||
|
"reviews",
|
||||||
|
);
|
||||||
context.insert(
|
context.insert(
|
||||||
"header_actions".into(),
|
"header_actions".into(),
|
||||||
json!([
|
json!([
|
||||||
@@ -1058,7 +1288,9 @@ pub async fn reviews_create(
|
|||||||
review_date: Set(Some(normalize_admin_text(&form.review_date))),
|
review_date: Set(Some(normalize_admin_text(&form.review_date))),
|
||||||
status: Set(Some(normalize_admin_text(&form.status))),
|
status: Set(Some(normalize_admin_text(&form.status))),
|
||||||
description: Set(Some(normalize_admin_text(&form.description))),
|
description: Set(Some(normalize_admin_text(&form.description))),
|
||||||
tags: Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default())),
|
tags: Set(Some(
|
||||||
|
serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default(),
|
||||||
|
)),
|
||||||
cover: Set(Some(normalize_admin_text(&form.cover))),
|
cover: Set(Some(normalize_admin_text(&form.cover))),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
@@ -1087,7 +1319,9 @@ pub async fn reviews_update(
|
|||||||
model.review_date = Set(Some(normalize_admin_text(&form.review_date)));
|
model.review_date = Set(Some(normalize_admin_text(&form.review_date)));
|
||||||
model.status = Set(Some(normalize_admin_text(&form.status)));
|
model.status = Set(Some(normalize_admin_text(&form.status)));
|
||||||
model.description = Set(Some(normalize_admin_text(&form.description)));
|
model.description = Set(Some(normalize_admin_text(&form.description)));
|
||||||
model.tags = Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default()));
|
model.tags = Set(Some(
|
||||||
|
serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default(),
|
||||||
|
));
|
||||||
model.cover = Set(Some(normalize_admin_text(&form.cover)));
|
model.cover = Set(Some(normalize_admin_text(&form.cover)));
|
||||||
let _ = model.update(&ctx.db).await?;
|
let _ = model.update(&ctx.db).await?;
|
||||||
|
|
||||||
@@ -1134,7 +1368,11 @@ pub async fn friend_links_admin(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let mut context = page_context("友链申请", "处理前台友链申请状态,并跳转到前台友链页或目标站点。", "friend_links");
|
let mut context = page_context(
|
||||||
|
"友链申请",
|
||||||
|
"处理前台友链申请状态,并跳转到前台友链页或目标站点。",
|
||||||
|
"friend_links",
|
||||||
|
);
|
||||||
context.insert(
|
context.insert(
|
||||||
"header_actions".into(),
|
"header_actions".into(),
|
||||||
json!([
|
json!([
|
||||||
@@ -1159,6 +1397,13 @@ fn tech_stack_text(item: &site_settings::Model) -> String {
|
|||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn indexed_at_text(item: &site_settings::Model) -> String {
|
||||||
|
item.ai_last_indexed_at
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.format("%Y-%m-%d %H:%M:%S UTC").to_string())
|
||||||
|
.unwrap_or_else(|| "尚未建立索引".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn site_settings_admin(
|
pub async fn site_settings_admin(
|
||||||
view_engine: ViewEngine<TeraView>,
|
view_engine: ViewEngine<TeraView>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
@@ -1171,8 +1416,13 @@ pub async fn site_settings_admin(
|
|||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(Error::NotFound)?;
|
.ok_or(Error::NotFound)?;
|
||||||
|
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
||||||
|
|
||||||
let mut context = page_context("站点设置", "修改首页、关于页、页脚和友链页读取的站点信息,并直接跳到前台预览。", "site_settings");
|
let mut context = page_context(
|
||||||
|
"站点设置",
|
||||||
|
"修改首页、关于页、页脚和友链页读取的站点信息,并直接跳到前台预览。",
|
||||||
|
"site_settings",
|
||||||
|
);
|
||||||
context.insert(
|
context.insert(
|
||||||
"header_actions".into(),
|
"header_actions".into(),
|
||||||
json!([
|
json!([
|
||||||
@@ -1201,6 +1451,17 @@ pub async fn site_settings_admin(
|
|||||||
"social_email": non_empty(item.social_email.as_deref(), ""),
|
"social_email": non_empty(item.social_email.as_deref(), ""),
|
||||||
"owner_bio": non_empty(item.owner_bio.as_deref(), ""),
|
"owner_bio": non_empty(item.owner_bio.as_deref(), ""),
|
||||||
"tech_stack": tech_stack_text(&item),
|
"tech_stack": tech_stack_text(&item),
|
||||||
|
"ai_enabled": item.ai_enabled.unwrap_or(false),
|
||||||
|
"ai_provider": non_empty(item.ai_provider.as_deref(), &ai::provider_name(None)),
|
||||||
|
"ai_api_base": non_empty(item.ai_api_base.as_deref(), ai::default_api_base()),
|
||||||
|
"ai_api_key": non_empty(item.ai_api_key.as_deref(), ""),
|
||||||
|
"ai_chat_model": non_empty(item.ai_chat_model.as_deref(), ai::default_chat_model()),
|
||||||
|
"ai_local_embedding": ai::local_embedding_label(),
|
||||||
|
"ai_system_prompt": non_empty(item.ai_system_prompt.as_deref(), ""),
|
||||||
|
"ai_top_k": item.ai_top_k.unwrap_or(4),
|
||||||
|
"ai_chunk_size": item.ai_chunk_size.unwrap_or(1200),
|
||||||
|
"ai_last_indexed_at": indexed_at_text(&item),
|
||||||
|
"ai_chunks_count": ai_chunks_count,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1217,7 +1478,10 @@ pub fn routes() -> Routes {
|
|||||||
.add("/admin/posts/import", post(posts_import))
|
.add("/admin/posts/import", post(posts_import))
|
||||||
.add("/admin/posts/{slug}/edit", get(post_editor))
|
.add("/admin/posts/{slug}/edit", get(post_editor))
|
||||||
.add("/admin/comments", get(comments_admin))
|
.add("/admin/comments", get(comments_admin))
|
||||||
.add("/admin/categories", get(categories_admin).post(categories_create))
|
.add(
|
||||||
|
"/admin/categories",
|
||||||
|
get(categories_admin).post(categories_create),
|
||||||
|
)
|
||||||
.add("/admin/categories/{id}/update", post(categories_update))
|
.add("/admin/categories/{id}/update", post(categories_update))
|
||||||
.add("/admin/categories/{id}/delete", post(categories_delete))
|
.add("/admin/categories/{id}/delete", post(categories_delete))
|
||||||
.add("/admin/tags", get(tags_admin).post(tags_create))
|
.add("/admin/tags", get(tags_admin).post(tags_create))
|
||||||
|
|||||||
369
backend/src/controllers/ai.rs
Normal file
369
backend/src/controllers/ai.rs
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
#![allow(clippy::unused_async)]
|
||||||
|
|
||||||
|
use async_stream::stream;
|
||||||
|
use axum::{
|
||||||
|
body::{Body, Bytes},
|
||||||
|
http::{
|
||||||
|
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
||||||
|
HeaderValue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::{controllers::admin::check_auth, services::ai};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct AskPayload {
|
||||||
|
pub question: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AskResponse {
|
||||||
|
pub question: String,
|
||||||
|
pub answer: String,
|
||||||
|
pub sources: Vec<ai::AiSource>,
|
||||||
|
pub indexed_chunks: usize,
|
||||||
|
pub last_indexed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct ReindexResponse {
|
||||||
|
pub indexed_chunks: usize,
|
||||||
|
pub last_indexed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct StreamStatusEvent {
|
||||||
|
phase: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct StreamDeltaEvent {
|
||||||
|
delta: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
struct StreamErrorEvent {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_timestamp(value: Option<DateTime<Utc>>) -> Option<String> {
|
||||||
|
value.map(|item| item.to_rfc3339())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_bytes<T: Serialize>(event: &str, payload: &T) -> Bytes {
|
||||||
|
let data = serde_json::to_string(payload).unwrap_or_else(|_| {
|
||||||
|
"{\"message\":\"failed to serialize SSE payload\"}".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
Bytes::from(format!("event: {event}\ndata: {data}\n\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_next_sse_event(buffer: &mut String) -> Option<(Option<String>, String)> {
|
||||||
|
let mut boundary = buffer.find("\n\n").map(|index| (index, 2));
|
||||||
|
if let Some(index) = buffer.find("\r\n\r\n") {
|
||||||
|
match boundary {
|
||||||
|
Some((existing, _)) if existing <= index => {}
|
||||||
|
_ => boundary = Some((index, 4)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (index, separator_len) = boundary?;
|
||||||
|
let raw = buffer[..index].to_string();
|
||||||
|
buffer.drain(..index + separator_len);
|
||||||
|
|
||||||
|
let normalized = raw.replace("\r\n", "\n");
|
||||||
|
let mut event = None;
|
||||||
|
let mut data_lines = Vec::new();
|
||||||
|
|
||||||
|
for line in normalized.lines() {
|
||||||
|
if let Some(value) = line.strip_prefix("event:") {
|
||||||
|
event = Some(value.trim().to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(value) = line.strip_prefix("data:") {
|
||||||
|
data_lines.push(value.trim_start().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((event, data_lines.join("\n")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_stream_delta(value: &Value) -> Option<String> {
|
||||||
|
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
|
||||||
|
return Some(delta.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(content) = value
|
||||||
|
.get("choices")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|choices| choices.first())
|
||||||
|
.and_then(|choice| choice.get("delta"))
|
||||||
|
.and_then(|delta| delta.get("content"))
|
||||||
|
{
|
||||||
|
if let Some(text) = content.as_str() {
|
||||||
|
return Some(text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parts) = content.as_array() {
|
||||||
|
let merged = parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| {
|
||||||
|
part.get("text")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| part.as_str())
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
if !merged.is_empty() {
|
||||||
|
return Some(merged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
value.get("choices")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|choices| choices.first())
|
||||||
|
.and_then(|choice| choice.get("text"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_missing_suffix(accumulated: &mut String, full_text: &str) -> Option<String> {
|
||||||
|
if full_text.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if accumulated.is_empty() {
|
||||||
|
accumulated.push_str(full_text);
|
||||||
|
return Some(full_text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if full_text.starts_with(accumulated.as_str()) {
|
||||||
|
let suffix = full_text[accumulated.len()..].to_string();
|
||||||
|
if !suffix.is_empty() {
|
||||||
|
accumulated.push_str(&suffix);
|
||||||
|
return Some(suffix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chunk_text(value: &str, chunk_size: usize) -> Vec<String> {
|
||||||
|
let chars = value.chars().collect::<Vec<_>>();
|
||||||
|
chars
|
||||||
|
.chunks(chunk_size.max(1))
|
||||||
|
.map(|chunk| chunk.iter().collect::<String>())
|
||||||
|
.filter(|chunk| !chunk.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_ask_response(prepared: &ai::PreparedAiAnswer, answer: String) -> AskResponse {
|
||||||
|
AskResponse {
|
||||||
|
question: prepared.question.clone(),
|
||||||
|
answer,
|
||||||
|
sources: prepared.sources.clone(),
|
||||||
|
indexed_chunks: prepared.indexed_chunks,
|
||||||
|
last_indexed_at: format_timestamp(prepared.last_indexed_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn ask(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(payload): Json<AskPayload>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let result = ai::answer_question(&ctx, &payload.question).await?;
|
||||||
|
format::json(AskResponse {
|
||||||
|
question: payload.question.trim().to_string(),
|
||||||
|
answer: result.answer,
|
||||||
|
sources: result.sources,
|
||||||
|
indexed_chunks: result.indexed_chunks,
|
||||||
|
last_indexed_at: format_timestamp(result.last_indexed_at),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn ask_stream(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(payload): Json<AskPayload>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let stream = stream! {
|
||||||
|
yield Ok::<Bytes, std::io::Error>(sse_bytes("status", &StreamStatusEvent {
|
||||||
|
phase: "retrieving".to_string(),
|
||||||
|
message: "正在检索知识库上下文...".to_string(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let prepared = match ai::prepare_answer(&ctx, &payload.question).await {
|
||||||
|
Ok(prepared) => prepared,
|
||||||
|
Err(error) => {
|
||||||
|
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||||
|
message: error.to_string(),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut accumulated_answer = String::new();
|
||||||
|
|
||||||
|
if let Some(answer) = prepared.immediate_answer.as_deref() {
|
||||||
|
yield Ok(sse_bytes("status", &StreamStatusEvent {
|
||||||
|
phase: "answering".to_string(),
|
||||||
|
message: "已完成检索,正在输出检索结论...".to_string(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
for chunk in chunk_text(answer, 48) {
|
||||||
|
accumulated_answer.push_str(&chunk);
|
||||||
|
yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta: chunk }));
|
||||||
|
}
|
||||||
|
} else if let Some(provider_request) = prepared.provider_request.as_ref() {
|
||||||
|
yield Ok(sse_bytes("status", &StreamStatusEvent {
|
||||||
|
phase: "streaming".to_string(),
|
||||||
|
message: "已命中相关资料,正在流式生成回答...".to_string(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(ai::build_provider_url(provider_request))
|
||||||
|
.bearer_auth(&provider_request.api_key)
|
||||||
|
.header("Accept", "text/event-stream, application/json")
|
||||||
|
.json(&ai::build_provider_payload(provider_request, true))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut response = match response {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => {
|
||||||
|
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||||
|
message: format!("AI request failed: {error}"),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||||
|
message: format!("AI provider returned {status}: {body}"),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sse_buffer = String::new();
|
||||||
|
let mut last_full_answer = None;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let next_chunk = response.chunk().await;
|
||||||
|
let Some(chunk) = (match next_chunk {
|
||||||
|
Ok(chunk) => chunk,
|
||||||
|
Err(error) => {
|
||||||
|
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||||
|
message: format!("AI stream read failed: {error}"),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
sse_buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||||
|
|
||||||
|
while let Some((_event_name, data)) = take_next_sse_event(&mut sse_buffer) {
|
||||||
|
let trimmed = data.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed == "[DONE]" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = match serde_json::from_str::<Value>(trimmed) {
|
||||||
|
Ok(parsed) => parsed,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(full_text) = ai::extract_provider_text(&parsed) {
|
||||||
|
last_full_answer = Some(full_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(delta) = extract_stream_delta(&parsed) {
|
||||||
|
accumulated_answer.push_str(&delta);
|
||||||
|
yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let leftover = sse_buffer.trim();
|
||||||
|
if !leftover.is_empty() && leftover != "[DONE]" {
|
||||||
|
if let Ok(parsed) = serde_json::from_str::<Value>(leftover) {
|
||||||
|
if let Some(full_text) = ai::extract_provider_text(&parsed) {
|
||||||
|
last_full_answer = Some(full_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(delta) = extract_stream_delta(&parsed) {
|
||||||
|
accumulated_answer.push_str(&delta);
|
||||||
|
yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(full_text) = last_full_answer {
|
||||||
|
if let Some(suffix) = append_missing_suffix(&mut accumulated_answer, &full_text) {
|
||||||
|
yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta: suffix }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if accumulated_answer.is_empty() {
|
||||||
|
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||||
|
message: "AI chat response did not contain readable content".to_string(),
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_payload = build_ask_response(&prepared, accumulated_answer);
|
||||||
|
yield Ok(sse_bytes("complete", &final_payload));
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut response = Response::new(Body::from_stream(stream));
|
||||||
|
response.headers_mut().insert(
|
||||||
|
CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("text/event-stream; charset=utf-8"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(CONNECTION, HeaderValue::from_static("keep-alive"));
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn reindex(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
|
check_auth()?;
|
||||||
|
let summary = ai::rebuild_index(&ctx).await?;
|
||||||
|
|
||||||
|
format::json(ReindexResponse {
|
||||||
|
indexed_chunks: summary.indexed_chunks,
|
||||||
|
last_indexed_at: format_timestamp(summary.last_indexed_at),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.prefix("api/ai/")
|
||||||
|
.add("/ask", post(ask))
|
||||||
|
.add("/ask/stream", post(ask_stream))
|
||||||
|
.add("/reindex", post(reindex))
|
||||||
|
}
|
||||||
@@ -136,16 +136,32 @@ pub async fn update(
|
|||||||
let name = normalized_name(¶ms)?;
|
let name = normalized_name(¶ms)?;
|
||||||
let slug = normalized_slug(¶ms, &name);
|
let slug = normalized_slug(¶ms, &name);
|
||||||
let item = load_item(&ctx, id).await?;
|
let item = load_item(&ctx, id).await?;
|
||||||
|
let previous_name = item.name.clone();
|
||||||
|
let previous_slug = item.slug.clone();
|
||||||
|
|
||||||
|
if previous_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
!= Some(name.as_str())
|
||||||
|
{
|
||||||
|
content::rewrite_category_references(previous_name.as_deref(), &previous_slug, Some(&name))?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut item = item.into_active_model();
|
let mut item = item.into_active_model();
|
||||||
item.name = Set(Some(name));
|
item.name = Set(Some(name));
|
||||||
item.slug = Set(slug);
|
item.slug = Set(slug);
|
||||||
let item = item.update(&ctx.db).await?;
|
let item = item.update(&ctx.db).await?;
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
format::json(item)
|
format::json(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
let item = load_item(&ctx, id).await?;
|
||||||
|
content::rewrite_category_references(item.name.as_deref(), &item.slug, None)?;
|
||||||
|
item.delete(&ctx.db).await?;
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
format::empty()
|
format::empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ColumnTrait, QueryFilter, QueryOrder};
|
use sea_orm::{ColumnTrait, QueryFilter, QueryOrder};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::models::_entities::{
|
use crate::models::_entities::{
|
||||||
comments::{ActiveModel, Column, Entity, Model},
|
comments::{ActiveModel, Column, Entity, Model},
|
||||||
posts,
|
posts,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ARTICLE_SCOPE: &str = "article";
|
||||||
|
const PARAGRAPH_SCOPE: &str = "paragraph";
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct Params {
|
pub struct Params {
|
||||||
pub post_id: Option<Uuid>,
|
pub post_id: Option<Uuid>,
|
||||||
@@ -19,6 +23,10 @@ pub struct Params {
|
|||||||
pub avatar: Option<String>,
|
pub avatar: Option<String>,
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
pub reply_to: Option<Uuid>,
|
pub reply_to: Option<Uuid>,
|
||||||
|
pub reply_to_comment_id: Option<i32>,
|
||||||
|
pub scope: Option<String>,
|
||||||
|
pub paragraph_key: Option<String>,
|
||||||
|
pub paragraph_excerpt: Option<String>,
|
||||||
pub approved: Option<bool>,
|
pub approved: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +53,18 @@ impl Params {
|
|||||||
if let Some(reply_to) = self.reply_to {
|
if let Some(reply_to) = self.reply_to {
|
||||||
item.reply_to = Set(Some(reply_to));
|
item.reply_to = Set(Some(reply_to));
|
||||||
}
|
}
|
||||||
|
if let Some(reply_to_comment_id) = self.reply_to_comment_id {
|
||||||
|
item.reply_to_comment_id = Set(Some(reply_to_comment_id));
|
||||||
|
}
|
||||||
|
if let Some(scope) = &self.scope {
|
||||||
|
item.scope = Set(scope.clone());
|
||||||
|
}
|
||||||
|
if let Some(paragraph_key) = &self.paragraph_key {
|
||||||
|
item.paragraph_key = Set(Some(paragraph_key.clone()));
|
||||||
|
}
|
||||||
|
if let Some(paragraph_excerpt) = &self.paragraph_excerpt {
|
||||||
|
item.paragraph_excerpt = Set(Some(paragraph_excerpt.clone()));
|
||||||
|
}
|
||||||
if let Some(approved) = self.approved {
|
if let Some(approved) = self.approved {
|
||||||
item.approved = Set(Some(approved));
|
item.approved = Set(Some(approved));
|
||||||
}
|
}
|
||||||
@@ -55,6 +75,8 @@ impl Params {
|
|||||||
pub struct ListQuery {
|
pub struct ListQuery {
|
||||||
pub post_id: Option<String>,
|
pub post_id: Option<String>,
|
||||||
pub post_slug: Option<String>,
|
pub post_slug: Option<String>,
|
||||||
|
pub scope: Option<String>,
|
||||||
|
pub paragraph_key: Option<String>,
|
||||||
pub approved: Option<bool>,
|
pub approved: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,10 +96,58 @@ pub struct CreateCommentRequest {
|
|||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
#[serde(default, alias = "replyTo")]
|
#[serde(default, alias = "replyTo")]
|
||||||
pub reply_to: Option<String>,
|
pub reply_to: Option<String>,
|
||||||
|
#[serde(default, alias = "replyToCommentId")]
|
||||||
|
pub reply_to_comment_id: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scope: Option<String>,
|
||||||
|
#[serde(default, alias = "paragraphKey")]
|
||||||
|
pub paragraph_key: Option<String>,
|
||||||
|
#[serde(default, alias = "paragraphExcerpt")]
|
||||||
|
pub paragraph_excerpt: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub approved: Option<bool>,
|
pub approved: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct ParagraphCommentSummary {
|
||||||
|
pub paragraph_key: String,
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_scope(value: Option<String>) -> Result<String> {
|
||||||
|
match value
|
||||||
|
.unwrap_or_else(|| ARTICLE_SCOPE.to_string())
|
||||||
|
.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
.as_str()
|
||||||
|
{
|
||||||
|
ARTICLE_SCOPE => Ok(ARTICLE_SCOPE.to_string()),
|
||||||
|
PARAGRAPH_SCOPE => Ok(PARAGRAPH_SCOPE.to_string()),
|
||||||
|
_ => Err(Error::BadRequest("invalid comment scope".to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview_excerpt(value: &str) -> Option<String> {
|
||||||
|
let flattened = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
let excerpt = flattened.chars().take(120).collect::<String>();
|
||||||
|
if excerpt.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(excerpt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
item.ok_or_else(|| Error::NotFound)
|
item.ok_or_else(|| Error::NotFound)
|
||||||
@@ -116,6 +186,19 @@ pub async fn list(
|
|||||||
db_query = db_query.filter(Column::PostSlug.eq(post_slug));
|
db_query = db_query.filter(Column::PostSlug.eq(post_slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(scope) = query.scope {
|
||||||
|
db_query = db_query.filter(Column::Scope.eq(scope.trim().to_lowercase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(paragraph_key) = query
|
||||||
|
.paragraph_key
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
db_query = db_query.filter(Column::ParagraphKey.eq(paragraph_key));
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(approved) = query.approved {
|
if let Some(approved) = query.approved {
|
||||||
db_query = db_query.filter(Column::Approved.eq(approved));
|
db_query = db_query.filter(Column::Approved.eq(approved));
|
||||||
}
|
}
|
||||||
@@ -123,18 +206,87 @@ pub async fn list(
|
|||||||
format::json(db_query.all(&ctx.db).await?)
|
format::json(db_query.all(&ctx.db).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn paragraph_summary(
|
||||||
|
Query(query): Query<ListQuery>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let post_slug = if let Some(post_slug) = query.post_slug {
|
||||||
|
Some(post_slug)
|
||||||
|
} else if let Some(post_id) = query.post_id {
|
||||||
|
resolve_post_slug(&ctx, &post_id).await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
.ok_or_else(|| Error::BadRequest("post_slug is required".to_string()))?;
|
||||||
|
|
||||||
|
let items = Entity::find()
|
||||||
|
.filter(Column::PostSlug.eq(post_slug))
|
||||||
|
.filter(Column::Scope.eq(PARAGRAPH_SCOPE))
|
||||||
|
.filter(Column::Approved.eq(true))
|
||||||
|
.order_by_asc(Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut counts = BTreeMap::<String, usize>::new();
|
||||||
|
for item in items {
|
||||||
|
let Some(paragraph_key) = item.paragraph_key.as_deref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = paragraph_key.trim();
|
||||||
|
if key.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
*counts.entry(key.to_string()).or_default() += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = counts
|
||||||
|
.into_iter()
|
||||||
|
.map(|(paragraph_key, count)| ParagraphCommentSummary { paragraph_key, count })
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
format::json(summary)
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn add(
|
pub async fn add(
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
Json(params): Json<CreateCommentRequest>,
|
Json(params): Json<CreateCommentRequest>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
|
let scope = normalized_scope(params.scope.clone())?;
|
||||||
let post_slug = if let Some(post_slug) = params.post_slug.as_deref() {
|
let post_slug = if let Some(post_slug) = params.post_slug.as_deref() {
|
||||||
Some(post_slug.to_string())
|
Some(post_slug.to_string())
|
||||||
} else if let Some(post_id) = params.post_id.as_deref() {
|
} else if let Some(post_id) = params.post_id.as_deref() {
|
||||||
resolve_post_slug(&ctx, post_id).await?
|
resolve_post_slug(&ctx, post_id).await?
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
}
|
||||||
|
.and_then(|value| normalize_optional_string(Some(value)));
|
||||||
|
|
||||||
|
let author = normalize_optional_string(params.author);
|
||||||
|
let email = normalize_optional_string(params.email);
|
||||||
|
let avatar = normalize_optional_string(params.avatar);
|
||||||
|
let content = normalize_optional_string(params.content);
|
||||||
|
let paragraph_key = normalize_optional_string(params.paragraph_key);
|
||||||
|
let paragraph_excerpt = normalize_optional_string(params.paragraph_excerpt)
|
||||||
|
.or_else(|| content.as_deref().and_then(preview_excerpt));
|
||||||
|
|
||||||
|
if post_slug.is_none() {
|
||||||
|
return Err(Error::BadRequest("post_slug is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if author.is_none() {
|
||||||
|
return Err(Error::BadRequest("author is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.is_none() {
|
||||||
|
return Err(Error::BadRequest("content is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if scope == PARAGRAPH_SCOPE && paragraph_key.is_none() {
|
||||||
|
return Err(Error::BadRequest("paragraph_key is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let mut item = ActiveModel {
|
let mut item = ActiveModel {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -144,14 +296,18 @@ pub async fn add(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|value| Uuid::parse_str(value).ok()));
|
.and_then(|value| Uuid::parse_str(value).ok()));
|
||||||
item.post_slug = Set(post_slug);
|
item.post_slug = Set(post_slug);
|
||||||
item.author = Set(params.author);
|
item.author = Set(author);
|
||||||
item.email = Set(params.email);
|
item.email = Set(email);
|
||||||
item.avatar = Set(params.avatar);
|
item.avatar = Set(avatar);
|
||||||
item.content = Set(params.content);
|
item.content = Set(content);
|
||||||
|
item.scope = Set(scope);
|
||||||
|
item.paragraph_key = Set(paragraph_key);
|
||||||
|
item.paragraph_excerpt = Set(paragraph_excerpt);
|
||||||
item.reply_to = Set(params
|
item.reply_to = Set(params
|
||||||
.reply_to
|
.reply_to
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.and_then(|value| Uuid::parse_str(value).ok()));
|
.and_then(|value| Uuid::parse_str(value).ok()));
|
||||||
|
item.reply_to_comment_id = Set(params.reply_to_comment_id);
|
||||||
item.approved = Set(Some(params.approved.unwrap_or(false)));
|
item.approved = Set(Some(params.approved.unwrap_or(false)));
|
||||||
let item = item.insert(&ctx.db).await?;
|
let item = item.insert(&ctx.db).await?;
|
||||||
format::json(item)
|
format::json(item)
|
||||||
@@ -185,6 +341,7 @@ pub fn routes() -> Routes {
|
|||||||
Routes::new()
|
Routes::new()
|
||||||
.prefix("api/comments/")
|
.prefix("api/comments/")
|
||||||
.add("/", get(list))
|
.add("/", get(list))
|
||||||
|
.add("paragraphs/summary", get(paragraph_summary))
|
||||||
.add("/", post(add))
|
.add("/", post(add))
|
||||||
.add("{id}", get(get_one))
|
.add("{id}", get(get_one))
|
||||||
.add("{id}", delete(remove))
|
.add("{id}", delete(remove))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
|
pub mod ai;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod category;
|
pub mod category;
|
||||||
pub mod comment;
|
pub mod comment;
|
||||||
|
|||||||
@@ -51,6 +51,20 @@ pub struct MarkdownUpdateParams {
|
|||||||
pub markdown: String,
|
pub markdown: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct MarkdownCreateParams {
|
||||||
|
pub title: String,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub tags: Option<Vec<String>>,
|
||||||
|
pub post_type: Option<String>,
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub pinned: Option<bool>,
|
||||||
|
pub published: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct MarkdownDocumentResponse {
|
pub struct MarkdownDocumentResponse {
|
||||||
pub slug: String,
|
pub slug: String,
|
||||||
@@ -58,6 +72,12 @@ pub struct MarkdownDocumentResponse {
|
|||||||
pub markdown: String,
|
pub markdown: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct MarkdownDeleteResponse {
|
||||||
|
pub slug: String,
|
||||||
|
pub deleted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||||
item.ok_or_else(|| Error::NotFound)
|
item.ok_or_else(|| Error::NotFound)
|
||||||
@@ -228,7 +248,11 @@ pub async fn get_markdown_by_slug(
|
|||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
content::sync_markdown_posts(&ctx).await?;
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
let (path, markdown) = content::read_markdown_document(&slug)?;
|
let (path, markdown) = content::read_markdown_document(&slug)?;
|
||||||
format::json(MarkdownDocumentResponse { slug, path, markdown })
|
format::json(MarkdownDocumentResponse {
|
||||||
|
slug,
|
||||||
|
path,
|
||||||
|
markdown,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -247,14 +271,64 @@ pub async fn update_markdown_by_slug(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn create_markdown(
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Json(params): Json<MarkdownCreateParams>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let title = params.title.trim();
|
||||||
|
if title.is_empty() {
|
||||||
|
return Err(Error::BadRequest("title is required".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let default_body = format!("# {title}\n");
|
||||||
|
let created = content::create_markdown_post(
|
||||||
|
&ctx,
|
||||||
|
content::MarkdownPostDraft {
|
||||||
|
title: title.to_string(),
|
||||||
|
slug: params.slug,
|
||||||
|
description: params.description,
|
||||||
|
content: params.content.unwrap_or(default_body),
|
||||||
|
category: params.category,
|
||||||
|
tags: params.tags.unwrap_or_default(),
|
||||||
|
post_type: params.post_type.unwrap_or_else(|| "article".to_string()),
|
||||||
|
image: params.image,
|
||||||
|
pinned: params.pinned.unwrap_or(false),
|
||||||
|
published: params.published.unwrap_or(true),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let (path, markdown) = content::read_markdown_document(&created.slug)?;
|
||||||
|
|
||||||
|
format::json(MarkdownDocumentResponse {
|
||||||
|
slug: created.slug,
|
||||||
|
path,
|
||||||
|
markdown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
pub async fn delete_markdown_by_slug(
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
content::delete_markdown_post(&ctx, &slug).await?;
|
||||||
|
format::json(MarkdownDeleteResponse {
|
||||||
|
slug,
|
||||||
|
deleted: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.prefix("api/posts/")
|
.prefix("api/posts/")
|
||||||
.add("/", get(list))
|
.add("/", get(list))
|
||||||
.add("/", post(add))
|
.add("/", post(add))
|
||||||
|
.add("markdown", post(create_markdown))
|
||||||
.add("slug/{slug}/markdown", get(get_markdown_by_slug))
|
.add("slug/{slug}/markdown", get(get_markdown_by_slug))
|
||||||
.add("slug/{slug}/markdown", put(update_markdown_by_slug))
|
.add("slug/{slug}/markdown", put(update_markdown_by_slug))
|
||||||
.add("slug/{slug}/markdown", patch(update_markdown_by_slug))
|
.add("slug/{slug}/markdown", patch(update_markdown_by_slug))
|
||||||
|
.add("slug/{slug}/markdown", delete(delete_markdown_by_slug))
|
||||||
.add("slug/{slug}", get(get_by_slug))
|
.add("slug/{slug}", get(get_by_slug))
|
||||||
.add("{id}", get(get_one))
|
.add("{id}", get(get_one))
|
||||||
.add("{id}", delete(remove))
|
.add("{id}", delete(remove))
|
||||||
|
|||||||
@@ -174,7 +174,10 @@ pub async fn search(
|
|||||||
[q.clone().into(), (limit as i64).into()],
|
[q.clone().into(), (limit as i64).into()],
|
||||||
);
|
);
|
||||||
|
|
||||||
match SearchResult::find_by_statement(statement).all(&ctx.db).await {
|
match SearchResult::find_by_statement(statement)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(rows) => rows,
|
Ok(rows) => rows,
|
||||||
Err(_) => fallback_search(&ctx, &q, limit).await?,
|
Err(_) => fallback_search(&ctx, &q, limit).await?,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ use loco_rs::prelude::*;
|
|||||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::models::_entities::site_settings::{self, ActiveModel, Entity, Model};
|
use crate::{
|
||||||
|
controllers::admin::check_auth,
|
||||||
|
models::_entities::site_settings::{self, ActiveModel, Entity, Model},
|
||||||
|
services::ai,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
pub struct SiteSettingsPayload {
|
pub struct SiteSettingsPayload {
|
||||||
@@ -42,6 +46,46 @@ pub struct SiteSettingsPayload {
|
|||||||
pub location: Option<String>,
|
pub location: Option<String>,
|
||||||
#[serde(default, alias = "techStack")]
|
#[serde(default, alias = "techStack")]
|
||||||
pub tech_stack: Option<Vec<String>>,
|
pub tech_stack: Option<Vec<String>>,
|
||||||
|
#[serde(default, alias = "aiEnabled")]
|
||||||
|
pub ai_enabled: Option<bool>,
|
||||||
|
#[serde(default, alias = "aiProvider")]
|
||||||
|
pub ai_provider: Option<String>,
|
||||||
|
#[serde(default, alias = "aiApiBase")]
|
||||||
|
pub ai_api_base: Option<String>,
|
||||||
|
#[serde(default, alias = "aiApiKey")]
|
||||||
|
pub ai_api_key: Option<String>,
|
||||||
|
#[serde(default, alias = "aiChatModel")]
|
||||||
|
pub ai_chat_model: Option<String>,
|
||||||
|
#[serde(default, alias = "aiEmbeddingModel")]
|
||||||
|
pub ai_embedding_model: Option<String>,
|
||||||
|
#[serde(default, alias = "aiSystemPrompt")]
|
||||||
|
pub ai_system_prompt: Option<String>,
|
||||||
|
#[serde(default, alias = "aiTopK")]
|
||||||
|
pub ai_top_k: Option<i32>,
|
||||||
|
#[serde(default, alias = "aiChunkSize")]
|
||||||
|
pub ai_chunk_size: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct PublicSiteSettingsResponse {
|
||||||
|
pub id: i32,
|
||||||
|
pub site_name: Option<String>,
|
||||||
|
pub site_short_name: Option<String>,
|
||||||
|
pub site_url: Option<String>,
|
||||||
|
pub site_title: Option<String>,
|
||||||
|
pub site_description: Option<String>,
|
||||||
|
pub hero_title: Option<String>,
|
||||||
|
pub hero_subtitle: Option<String>,
|
||||||
|
pub owner_name: Option<String>,
|
||||||
|
pub owner_title: Option<String>,
|
||||||
|
pub owner_bio: Option<String>,
|
||||||
|
pub owner_avatar_url: Option<String>,
|
||||||
|
pub social_github: Option<String>,
|
||||||
|
pub social_twitter: Option<String>,
|
||||||
|
pub social_email: Option<String>,
|
||||||
|
pub location: Option<String>,
|
||||||
|
pub tech_stack: Option<serde_json::Value>,
|
||||||
|
pub ai_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||||
@@ -55,6 +99,10 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32> {
|
||||||
|
value.map(|item| item.clamp(min, max))
|
||||||
|
}
|
||||||
|
|
||||||
impl SiteSettingsPayload {
|
impl SiteSettingsPayload {
|
||||||
fn apply(self, item: &mut ActiveModel) {
|
fn apply(self, item: &mut ActiveModel) {
|
||||||
if let Some(site_name) = self.site_name {
|
if let Some(site_name) = self.site_name {
|
||||||
@@ -105,6 +153,33 @@ impl SiteSettingsPayload {
|
|||||||
if let Some(tech_stack) = self.tech_stack {
|
if let Some(tech_stack) = self.tech_stack {
|
||||||
item.tech_stack = Set(Some(serde_json::json!(tech_stack)));
|
item.tech_stack = Set(Some(serde_json::json!(tech_stack)));
|
||||||
}
|
}
|
||||||
|
if let Some(ai_enabled) = self.ai_enabled {
|
||||||
|
item.ai_enabled = Set(Some(ai_enabled));
|
||||||
|
}
|
||||||
|
if let Some(ai_provider) = self.ai_provider {
|
||||||
|
item.ai_provider = Set(normalize_optional_string(Some(ai_provider)));
|
||||||
|
}
|
||||||
|
if let Some(ai_api_base) = self.ai_api_base {
|
||||||
|
item.ai_api_base = Set(normalize_optional_string(Some(ai_api_base)));
|
||||||
|
}
|
||||||
|
if let Some(ai_api_key) = self.ai_api_key {
|
||||||
|
item.ai_api_key = Set(normalize_optional_string(Some(ai_api_key)));
|
||||||
|
}
|
||||||
|
if let Some(ai_chat_model) = self.ai_chat_model {
|
||||||
|
item.ai_chat_model = Set(normalize_optional_string(Some(ai_chat_model)));
|
||||||
|
}
|
||||||
|
if let Some(ai_embedding_model) = self.ai_embedding_model {
|
||||||
|
item.ai_embedding_model = Set(normalize_optional_string(Some(ai_embedding_model)));
|
||||||
|
}
|
||||||
|
if let Some(ai_system_prompt) = self.ai_system_prompt {
|
||||||
|
item.ai_system_prompt = Set(normalize_optional_string(Some(ai_system_prompt)));
|
||||||
|
}
|
||||||
|
if self.ai_top_k.is_some() {
|
||||||
|
item.ai_top_k = Set(normalize_optional_int(self.ai_top_k, 1, 12));
|
||||||
|
}
|
||||||
|
if self.ai_chunk_size.is_some() {
|
||||||
|
item.ai_chunk_size = Set(normalize_optional_int(self.ai_chunk_size, 400, 4000));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +209,22 @@ fn default_payload() -> SiteSettingsPayload {
|
|||||||
"Tailwind CSS".to_string(),
|
"Tailwind CSS".to_string(),
|
||||||
"TypeScript".to_string(),
|
"TypeScript".to_string(),
|
||||||
]),
|
]),
|
||||||
|
ai_enabled: Some(false),
|
||||||
|
ai_provider: Some(ai::provider_name(None)),
|
||||||
|
ai_api_base: Some(ai::default_api_base().to_string()),
|
||||||
|
ai_api_key: Some(ai::default_api_key().to_string()),
|
||||||
|
ai_chat_model: Some(ai::default_chat_model().to_string()),
|
||||||
|
ai_embedding_model: Some(ai::local_embedding_label().to_string()),
|
||||||
|
ai_system_prompt: Some(
|
||||||
|
"你是这个博客的站内 AI 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
ai_top_k: Some(4),
|
||||||
|
ai_chunk_size: Some(1200),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_current(ctx: &AppContext) -> Result<Model> {
|
pub(crate) async fn load_current(ctx: &AppContext) -> Result<Model> {
|
||||||
if let Some(settings) = Entity::find()
|
if let Some(settings) = Entity::find()
|
||||||
.order_by_asc(site_settings::Column::Id)
|
.order_by_asc(site_settings::Column::Id)
|
||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
@@ -154,9 +241,32 @@ async fn load_current(ctx: &AppContext) -> Result<Model> {
|
|||||||
Ok(item.insert(&ctx.db).await?)
|
Ok(item.insert(&ctx.db).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||||||
|
PublicSiteSettingsResponse {
|
||||||
|
id: model.id,
|
||||||
|
site_name: model.site_name,
|
||||||
|
site_short_name: model.site_short_name,
|
||||||
|
site_url: model.site_url,
|
||||||
|
site_title: model.site_title,
|
||||||
|
site_description: model.site_description,
|
||||||
|
hero_title: model.hero_title,
|
||||||
|
hero_subtitle: model.hero_subtitle,
|
||||||
|
owner_name: model.owner_name,
|
||||||
|
owner_title: model.owner_title,
|
||||||
|
owner_bio: model.owner_bio,
|
||||||
|
owner_avatar_url: model.owner_avatar_url,
|
||||||
|
social_github: model.social_github,
|
||||||
|
social_twitter: model.social_twitter,
|
||||||
|
social_email: model.social_email,
|
||||||
|
location: model.location,
|
||||||
|
tech_stack: model.tech_stack,
|
||||||
|
ai_enabled: model.ai_enabled.unwrap_or(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
format::json(load_current(&ctx).await?)
|
format::json(public_response(load_current(&ctx).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -164,10 +274,13 @@ pub async fn update(
|
|||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
Json(params): Json<SiteSettingsPayload>,
|
Json(params): Json<SiteSettingsPayload>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
|
check_auth()?;
|
||||||
|
|
||||||
let current = load_current(&ctx).await?;
|
let current = load_current(&ctx).await?;
|
||||||
let mut item = current.into_active_model();
|
let mut item = current.into_active_model();
|
||||||
params.apply(&mut item);
|
params.apply(&mut item);
|
||||||
format::json(item.update(&ctx.db).await?)
|
let updated = item.update(&ctx.db).await?;
|
||||||
|
format::json(public_response(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
|
|||||||
@@ -48,15 +48,38 @@ pub async fn update(
|
|||||||
Json(params): Json<Params>,
|
Json(params): Json<Params>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let item = load_item(&ctx, id).await?;
|
let item = load_item(&ctx, id).await?;
|
||||||
|
let previous_name = item.name.clone();
|
||||||
|
let previous_slug = item.slug.clone();
|
||||||
|
let next_name = params
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
|
||||||
|
if let Some(next_name) = next_name {
|
||||||
|
if previous_name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
!= Some(next_name)
|
||||||
|
{
|
||||||
|
content::rewrite_tag_references(previous_name.as_deref(), &previous_slug, Some(next_name))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut item = item.into_active_model();
|
let mut item = item.into_active_model();
|
||||||
params.update(&mut item);
|
params.update(&mut item);
|
||||||
let item = item.update(&ctx.db).await?;
|
let item = item.update(&ctx.db).await?;
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
format::json(item)
|
format::json(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
let item = load_item(&ctx, id).await?;
|
||||||
|
content::rewrite_tag_references(item.name.as_deref(), &item.slug, None)?;
|
||||||
|
item.delete(&ctx.db).await?;
|
||||||
|
content::sync_markdown_posts(&ctx).await?;
|
||||||
format::empty()
|
format::empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,12 @@
|
|||||||
- "Svelte"
|
- "Svelte"
|
||||||
- "Tailwind CSS"
|
- "Tailwind CSS"
|
||||||
- "TypeScript"
|
- "TypeScript"
|
||||||
|
ai_enabled: false
|
||||||
|
ai_provider: "newapi"
|
||||||
|
ai_api_base: "http://localhost:8317/v1"
|
||||||
|
ai_api_key: "your-api-key-1"
|
||||||
|
ai_chat_model: "gpt-5.4"
|
||||||
|
ai_embedding_model: "fastembed / local all-MiniLM-L6-v2"
|
||||||
|
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。"
|
||||||
|
ai_top_k: 4
|
||||||
|
ai_chunk_size: 1200
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ fn is_blank(value: &Option<String>) -> bool {
|
|||||||
value.as_deref().map(str::trim).unwrap_or("").is_empty()
|
value.as_deref().map(str::trim).unwrap_or("").is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn matches_legacy_ai_defaults(settings: &site_settings::Model) -> bool {
|
||||||
|
settings.ai_provider.as_deref().map(str::trim) == Some("openai-compatible")
|
||||||
|
&& settings.ai_api_base.as_deref().map(str::trim) == Some("https://api.openai.com/v1")
|
||||||
|
&& settings.ai_chat_model.as_deref().map(str::trim) == Some("gpt-4.1-mini")
|
||||||
|
&& is_blank(&settings.ai_api_key)
|
||||||
|
}
|
||||||
|
|
||||||
async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||||
let rows = read_fixture_rows(base, "site_settings.yaml");
|
let rows = read_fixture_rows(base, "site_settings.yaml");
|
||||||
let Some(seed) = rows.first() else {
|
let Some(seed) = rows.first() else {
|
||||||
@@ -81,6 +88,7 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
|||||||
|
|
||||||
if let Some(existing) = existing {
|
if let Some(existing) = existing {
|
||||||
let mut model = existing.clone().into_active_model();
|
let mut model = existing.clone().into_active_model();
|
||||||
|
let should_upgrade_legacy_ai_defaults = matches_legacy_ai_defaults(&existing);
|
||||||
|
|
||||||
if is_blank(&existing.site_name) {
|
if is_blank(&existing.site_name) {
|
||||||
model.site_name = Set(as_optional_string(&seed["site_name"]));
|
model.site_name = Set(as_optional_string(&seed["site_name"]));
|
||||||
@@ -130,6 +138,39 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
|||||||
if existing.tech_stack.is_none() {
|
if existing.tech_stack.is_none() {
|
||||||
model.tech_stack = Set(tech_stack);
|
model.tech_stack = Set(tech_stack);
|
||||||
}
|
}
|
||||||
|
if existing.ai_enabled.is_none() {
|
||||||
|
model.ai_enabled = Set(seed["ai_enabled"].as_bool());
|
||||||
|
}
|
||||||
|
if should_upgrade_legacy_ai_defaults {
|
||||||
|
model.ai_provider = Set(as_optional_string(&seed["ai_provider"]));
|
||||||
|
model.ai_api_base = Set(as_optional_string(&seed["ai_api_base"]));
|
||||||
|
model.ai_api_key = Set(as_optional_string(&seed["ai_api_key"]));
|
||||||
|
model.ai_chat_model = Set(as_optional_string(&seed["ai_chat_model"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.ai_provider) {
|
||||||
|
model.ai_provider = Set(as_optional_string(&seed["ai_provider"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.ai_api_base) {
|
||||||
|
model.ai_api_base = Set(as_optional_string(&seed["ai_api_base"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.ai_api_key) {
|
||||||
|
model.ai_api_key = Set(as_optional_string(&seed["ai_api_key"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.ai_chat_model) {
|
||||||
|
model.ai_chat_model = Set(as_optional_string(&seed["ai_chat_model"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.ai_embedding_model) {
|
||||||
|
model.ai_embedding_model = Set(as_optional_string(&seed["ai_embedding_model"]));
|
||||||
|
}
|
||||||
|
if is_blank(&existing.ai_system_prompt) {
|
||||||
|
model.ai_system_prompt = Set(as_optional_string(&seed["ai_system_prompt"]));
|
||||||
|
}
|
||||||
|
if existing.ai_top_k.is_none() {
|
||||||
|
model.ai_top_k = Set(seed["ai_top_k"].as_i64().map(|value| value as i32));
|
||||||
|
}
|
||||||
|
if existing.ai_chunk_size.is_none() {
|
||||||
|
model.ai_chunk_size = Set(seed["ai_chunk_size"].as_i64().map(|value| value as i32));
|
||||||
|
}
|
||||||
|
|
||||||
let _ = model.update(&ctx.db).await;
|
let _ = model.update(&ctx.db).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -153,6 +194,15 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
|||||||
social_email: Set(as_optional_string(&seed["social_email"])),
|
social_email: Set(as_optional_string(&seed["social_email"])),
|
||||||
location: Set(as_optional_string(&seed["location"])),
|
location: Set(as_optional_string(&seed["location"])),
|
||||||
tech_stack: Set(tech_stack),
|
tech_stack: Set(tech_stack),
|
||||||
|
ai_enabled: Set(seed["ai_enabled"].as_bool()),
|
||||||
|
ai_provider: Set(as_optional_string(&seed["ai_provider"])),
|
||||||
|
ai_api_base: Set(as_optional_string(&seed["ai_api_base"])),
|
||||||
|
ai_api_key: Set(as_optional_string(&seed["ai_api_key"])),
|
||||||
|
ai_chat_model: Set(as_optional_string(&seed["ai_chat_model"])),
|
||||||
|
ai_embedding_model: Set(as_optional_string(&seed["ai_embedding_model"])),
|
||||||
|
ai_system_prompt: Set(as_optional_string(&seed["ai_system_prompt"])),
|
||||||
|
ai_top_k: Set(seed["ai_top_k"].as_i64().map(|value| value as i32)),
|
||||||
|
ai_chunk_size: Set(seed["ai_chunk_size"].as_i64().map(|value| value as i32)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
28
backend/src/models/_entities/ai_chunks.rs
Normal file
28
backend/src/models/_entities/ai_chunks.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
//! `SeaORM` Entity, manually maintained
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "ai_chunks")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub source_slug: String,
|
||||||
|
pub source_title: Option<String>,
|
||||||
|
pub source_path: Option<String>,
|
||||||
|
pub source_type: String,
|
||||||
|
pub chunk_index: i32,
|
||||||
|
#[sea_orm(column_type = "Text")]
|
||||||
|
pub content: String,
|
||||||
|
pub content_preview: Option<String>,
|
||||||
|
pub embedding: Option<String>,
|
||||||
|
pub word_count: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@@ -17,7 +17,11 @@ pub struct Model {
|
|||||||
pub avatar: Option<String>,
|
pub avatar: Option<String>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
|
pub scope: String,
|
||||||
|
pub paragraph_key: Option<String>,
|
||||||
|
pub paragraph_excerpt: Option<String>,
|
||||||
pub reply_to: Option<Uuid>,
|
pub reply_to: Option<Uuid>,
|
||||||
|
pub reply_to_comment_id: Option<i32>,
|
||||||
pub approved: Option<bool>,
|
pub approved: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||||
|
|
||||||
|
pub mod ai_chunks;
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
pub mod categories;
|
pub mod categories;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||||
|
|
||||||
|
pub use super::ai_chunks::Entity as AiChunks;
|
||||||
pub use super::categories::Entity as Categories;
|
pub use super::categories::Entity as Categories;
|
||||||
pub use super::comments::Entity as Comments;
|
pub use super::comments::Entity as Comments;
|
||||||
pub use super::friend_links::Entity as FriendLinks;
|
pub use super::friend_links::Entity as FriendLinks;
|
||||||
|
|||||||
@@ -28,6 +28,18 @@ pub struct Model {
|
|||||||
pub location: Option<String>,
|
pub location: Option<String>,
|
||||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||||
pub tech_stack: Option<Json>,
|
pub tech_stack: Option<Json>,
|
||||||
|
pub ai_enabled: Option<bool>,
|
||||||
|
pub ai_provider: Option<String>,
|
||||||
|
pub ai_api_base: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub ai_api_key: Option<String>,
|
||||||
|
pub ai_chat_model: Option<String>,
|
||||||
|
pub ai_embedding_model: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub ai_system_prompt: Option<String>,
|
||||||
|
pub ai_top_k: Option<i32>,
|
||||||
|
pub ai_chunk_size: Option<i32>,
|
||||||
|
pub ai_last_indexed_at: Option<DateTimeWithTimeZone>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
3
backend/src/models/ai_chunks.rs
Normal file
3
backend/src/models/ai_chunks.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub use super::_entities::ai_chunks::{ActiveModel, Entity, Model};
|
||||||
|
|
||||||
|
pub type AiChunks = Entity;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod _entities;
|
pub mod _entities;
|
||||||
|
pub mod ai_chunks;
|
||||||
pub mod categories;
|
pub mod categories;
|
||||||
pub mod comments;
|
pub mod comments;
|
||||||
pub mod friend_links;
|
pub mod friend_links;
|
||||||
|
|||||||
993
backend/src/services/ai.rs
Normal file
993
backend/src/services/ai.rs
Normal file
@@ -0,0 +1,993 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use fastembed::{
|
||||||
|
InitOptionsUserDefined, Pooling, TextEmbedding, TokenizerFiles, UserDefinedEmbeddingModel,
|
||||||
|
};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use reqwest::Client;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
|
||||||
|
PaginatorTrait, QueryOrder, Set, Statement,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::_entities::{ai_chunks, site_settings},
|
||||||
|
services::content,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_AI_PROVIDER: &str = "newapi";
|
||||||
|
const DEFAULT_AI_API_BASE: &str = "http://localhost:8317/v1";
|
||||||
|
const DEFAULT_AI_API_KEY: &str = "your-api-key-1";
|
||||||
|
const DEFAULT_CHAT_MODEL: &str = "gpt-5.4";
|
||||||
|
const DEFAULT_REASONING_EFFORT: &str = "medium";
|
||||||
|
const DEFAULT_DISABLE_RESPONSE_STORAGE: bool = true;
|
||||||
|
const DEFAULT_TOP_K: usize = 4;
|
||||||
|
const DEFAULT_CHUNK_SIZE: usize = 1200;
|
||||||
|
const DEFAULT_SYSTEM_PROMPT: &str =
|
||||||
|
"你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。";
|
||||||
|
const EMBEDDING_BATCH_SIZE: usize = 32;
|
||||||
|
const EMBEDDING_DIMENSION: usize = 384;
|
||||||
|
const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2";
|
||||||
|
const LOCAL_EMBEDDING_CACHE_DIR: &str = "storage/ai_embedding_models/all-minilm-l6-v2";
|
||||||
|
const LOCAL_EMBEDDING_BASE_URL: &str =
|
||||||
|
"https://huggingface.co/Qdrant/all-MiniLM-L6-v2-onnx/resolve/main";
|
||||||
|
const LOCAL_EMBEDDING_FILES: [&str; 5] = [
|
||||||
|
"model.onnx",
|
||||||
|
"tokenizer.json",
|
||||||
|
"config.json",
|
||||||
|
"special_tokens_map.json",
|
||||||
|
"tokenizer_config.json",
|
||||||
|
];
|
||||||
|
|
||||||
|
static TEXT_EMBEDDING_MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new();
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct AiRuntimeSettings {
|
||||||
|
raw: site_settings::Model,
|
||||||
|
provider: String,
|
||||||
|
api_base: Option<String>,
|
||||||
|
api_key: Option<String>,
|
||||||
|
chat_model: String,
|
||||||
|
system_prompt: String,
|
||||||
|
top_k: usize,
|
||||||
|
chunk_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ChunkDraft {
|
||||||
|
source_slug: String,
|
||||||
|
source_title: Option<String>,
|
||||||
|
source_path: Option<String>,
|
||||||
|
source_type: String,
|
||||||
|
chunk_index: i32,
|
||||||
|
content: String,
|
||||||
|
content_preview: Option<String>,
|
||||||
|
word_count: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ScoredChunk {
|
||||||
|
score: f64,
|
||||||
|
row: ai_chunks::Model,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, FromQueryResult)]
|
||||||
|
struct SimilarChunkRow {
|
||||||
|
source_slug: String,
|
||||||
|
source_title: Option<String>,
|
||||||
|
chunk_index: i32,
|
||||||
|
content: String,
|
||||||
|
content_preview: Option<String>,
|
||||||
|
word_count: Option<i32>,
|
||||||
|
score: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
enum EmbeddingKind {
|
||||||
|
Passage,
|
||||||
|
Query,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct AiSource {
|
||||||
|
pub slug: String,
|
||||||
|
pub title: String,
|
||||||
|
pub excerpt: String,
|
||||||
|
pub score: f64,
|
||||||
|
pub chunk_index: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AiAnswer {
|
||||||
|
pub answer: String,
|
||||||
|
pub sources: Vec<AiSource>,
|
||||||
|
pub indexed_chunks: usize,
|
||||||
|
pub last_indexed_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct AiProviderRequest {
|
||||||
|
pub(crate) provider: String,
|
||||||
|
pub(crate) api_base: String,
|
||||||
|
pub(crate) api_key: String,
|
||||||
|
pub(crate) chat_model: String,
|
||||||
|
pub(crate) system_prompt: String,
|
||||||
|
pub(crate) prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct PreparedAiAnswer {
|
||||||
|
pub(crate) question: String,
|
||||||
|
pub(crate) provider_request: Option<AiProviderRequest>,
|
||||||
|
pub(crate) immediate_answer: Option<String>,
|
||||||
|
pub(crate) sources: Vec<AiSource>,
|
||||||
|
pub(crate) indexed_chunks: usize,
|
||||||
|
pub(crate) last_indexed_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AiIndexSummary {
|
||||||
|
pub indexed_chunks: usize,
|
||||||
|
pub last_indexed_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||||
|
value.and_then(|item| {
|
||||||
|
let trimmed = item.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview_text(content: &str, limit: usize) -> Option<String> {
|
||||||
|
let flattened = content
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if flattened.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let preview = flattened.chars().take(limit).collect::<String>();
|
||||||
|
Some(preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_endpoint(api_base: &str, path: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
api_base.trim_end_matches('/'),
|
||||||
|
path.trim_start_matches('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_embedding_dir() -> PathBuf {
|
||||||
|
PathBuf::from(LOCAL_EMBEDDING_CACHE_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn download_embedding_file(
|
||||||
|
client: &reqwest::blocking::Client,
|
||||||
|
directory: &Path,
|
||||||
|
file_name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let target_path = directory.join(file_name);
|
||||||
|
if target_path.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{LOCAL_EMBEDDING_BASE_URL}/{file_name}");
|
||||||
|
let bytes = client
|
||||||
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.and_then(reqwest::blocking::Response::error_for_status)
|
||||||
|
.map_err(|error| Error::BadRequest(format!("下载本地 embedding 文件失败: {error}")))?
|
||||||
|
.bytes()
|
||||||
|
.map_err(|error| Error::BadRequest(format!("读取本地 embedding 文件失败: {error}")))?;
|
||||||
|
|
||||||
|
fs::write(&target_path, &bytes)
|
||||||
|
.map_err(|error| Error::BadRequest(format!("写入本地 embedding 文件失败: {error}")))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_local_embedding_files() -> Result<PathBuf> {
|
||||||
|
let directory = local_embedding_dir();
|
||||||
|
fs::create_dir_all(&directory)
|
||||||
|
.map_err(|error| Error::BadRequest(format!("创建本地 embedding 目录失败: {error}")))?;
|
||||||
|
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.build()
|
||||||
|
.map_err(|error| {
|
||||||
|
Error::BadRequest(format!("创建本地 embedding 下载客户端失败: {error}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for file_name in LOCAL_EMBEDDING_FILES {
|
||||||
|
download_embedding_file(&client, &directory, file_name)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_local_embedding_model() -> Result<TextEmbedding> {
|
||||||
|
let directory = ensure_local_embedding_files()?;
|
||||||
|
let tokenizer_files = TokenizerFiles {
|
||||||
|
tokenizer_file: fs::read(directory.join("tokenizer.json"))
|
||||||
|
.map_err(|error| Error::BadRequest(format!("读取 tokenizer.json 失败: {error}")))?,
|
||||||
|
config_file: fs::read(directory.join("config.json"))
|
||||||
|
.map_err(|error| Error::BadRequest(format!("读取 config.json 失败: {error}")))?,
|
||||||
|
special_tokens_map_file: fs::read(directory.join("special_tokens_map.json")).map_err(
|
||||||
|
|error| Error::BadRequest(format!("读取 special_tokens_map.json 失败: {error}")),
|
||||||
|
)?,
|
||||||
|
tokenizer_config_file: fs::read(directory.join("tokenizer_config.json")).map_err(
|
||||||
|
|error| Error::BadRequest(format!("读取 tokenizer_config.json 失败: {error}")),
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let model = UserDefinedEmbeddingModel::new(
|
||||||
|
fs::read(directory.join("model.onnx"))
|
||||||
|
.map_err(|error| Error::BadRequest(format!("读取 model.onnx 失败: {error}")))?,
|
||||||
|
tokenizer_files,
|
||||||
|
)
|
||||||
|
.with_pooling(Pooling::Mean);
|
||||||
|
|
||||||
|
TextEmbedding::try_new_from_user_defined(model, InitOptionsUserDefined::default())
|
||||||
|
.map_err(|error| Error::BadRequest(format!("本地 embedding 模型初始化失败: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_embedding_engine() -> Result<&'static Mutex<TextEmbedding>> {
|
||||||
|
if let Some(model) = TEXT_EMBEDDING_MODEL.get() {
|
||||||
|
return Ok(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = load_local_embedding_model()?;
|
||||||
|
|
||||||
|
let _ = TEXT_EMBEDDING_MODEL.set(Mutex::new(model));
|
||||||
|
|
||||||
|
TEXT_EMBEDDING_MODEL
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| Error::BadRequest("本地 embedding 模型未能成功缓存".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vector_literal(embedding: &[f64]) -> Result<String> {
|
||||||
|
if embedding.len() != EMBEDDING_DIMENSION {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"embedding 维度异常,期望 {EMBEDDING_DIMENSION},实际 {}",
|
||||||
|
embedding.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"[{}]",
|
||||||
|
embedding
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_embedding_text(kind: EmbeddingKind, text: &str) -> String {
|
||||||
|
match kind {
|
||||||
|
EmbeddingKind::Passage | EmbeddingKind::Query => text.trim().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_long_text(text: &str, chunk_size: usize) -> Vec<String> {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let candidate = if current.is_empty() {
|
||||||
|
line.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{current}\n{line}")
|
||||||
|
};
|
||||||
|
|
||||||
|
if candidate.chars().count() > chunk_size && !current.is_empty() {
|
||||||
|
parts.push(current.trim().to_string());
|
||||||
|
current = line.to_string();
|
||||||
|
} else {
|
||||||
|
current = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !current.trim().is_empty() {
|
||||||
|
parts.push(current.trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
parts
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_chunks(posts: &[content::MarkdownPost], chunk_size: usize) -> Vec<ChunkDraft> {
|
||||||
|
let mut chunks = Vec::new();
|
||||||
|
|
||||||
|
for post in posts.iter().filter(|post| post.published) {
|
||||||
|
let mut sections = Vec::new();
|
||||||
|
sections.push(format!("# {}", post.title));
|
||||||
|
if let Some(description) = post
|
||||||
|
.description
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
sections.push(description.trim().to_string());
|
||||||
|
}
|
||||||
|
sections.push(post.content.trim().to_string());
|
||||||
|
|
||||||
|
let source_text = sections
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| !item.trim().is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
let paragraphs = source_text
|
||||||
|
.split("\n\n")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut buffer = String::new();
|
||||||
|
let mut chunk_index = 0_i32;
|
||||||
|
|
||||||
|
for paragraph in paragraphs {
|
||||||
|
if paragraph.chars().count() > chunk_size {
|
||||||
|
if !buffer.trim().is_empty() {
|
||||||
|
chunks.push(ChunkDraft {
|
||||||
|
source_slug: post.slug.clone(),
|
||||||
|
source_title: Some(post.title.clone()),
|
||||||
|
source_path: Some(post.file_path.clone()),
|
||||||
|
source_type: "post".to_string(),
|
||||||
|
chunk_index,
|
||||||
|
content: buffer.trim().to_string(),
|
||||||
|
content_preview: preview_text(&buffer, 180),
|
||||||
|
word_count: Some(buffer.split_whitespace().count() as i32),
|
||||||
|
});
|
||||||
|
chunk_index += 1;
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for part in split_long_text(paragraph, chunk_size) {
|
||||||
|
if part.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(ChunkDraft {
|
||||||
|
source_slug: post.slug.clone(),
|
||||||
|
source_title: Some(post.title.clone()),
|
||||||
|
source_path: Some(post.file_path.clone()),
|
||||||
|
source_type: "post".to_string(),
|
||||||
|
chunk_index,
|
||||||
|
content_preview: preview_text(&part, 180),
|
||||||
|
word_count: Some(part.split_whitespace().count() as i32),
|
||||||
|
content: part,
|
||||||
|
});
|
||||||
|
chunk_index += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate = if buffer.is_empty() {
|
||||||
|
paragraph.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{buffer}\n\n{paragraph}")
|
||||||
|
};
|
||||||
|
|
||||||
|
if candidate.chars().count() > chunk_size && !buffer.trim().is_empty() {
|
||||||
|
chunks.push(ChunkDraft {
|
||||||
|
source_slug: post.slug.clone(),
|
||||||
|
source_title: Some(post.title.clone()),
|
||||||
|
source_path: Some(post.file_path.clone()),
|
||||||
|
source_type: "post".to_string(),
|
||||||
|
chunk_index,
|
||||||
|
content_preview: preview_text(&buffer, 180),
|
||||||
|
word_count: Some(buffer.split_whitespace().count() as i32),
|
||||||
|
content: buffer.trim().to_string(),
|
||||||
|
});
|
||||||
|
chunk_index += 1;
|
||||||
|
buffer = paragraph.to_string();
|
||||||
|
} else {
|
||||||
|
buffer = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !buffer.trim().is_empty() {
|
||||||
|
chunks.push(ChunkDraft {
|
||||||
|
source_slug: post.slug.clone(),
|
||||||
|
source_title: Some(post.title.clone()),
|
||||||
|
source_path: Some(post.file_path.clone()),
|
||||||
|
source_type: "post".to_string(),
|
||||||
|
chunk_index,
|
||||||
|
content_preview: preview_text(&buffer, 180),
|
||||||
|
word_count: Some(buffer.split_whitespace().count() as i32),
|
||||||
|
content: buffer.trim().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value) -> Result<Value> {
|
||||||
|
let response = client
|
||||||
|
.post(url)
|
||||||
|
.bearer_auth(api_key)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("AI request failed: {error}")))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("AI response read failed: {error}")))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"AI provider returned {status}: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str(&body)
|
||||||
|
.map_err(|error| Error::BadRequest(format!("AI response parse failed: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider_uses_responses(provider: &str) -> bool {
|
||||||
|
provider.eq_ignore_ascii_case("newapi")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let model = local_embedding_engine()?;
|
||||||
|
let prepared = inputs
|
||||||
|
.iter()
|
||||||
|
.map(|item| prepare_embedding_text(kind, item))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut guard = model.lock().map_err(|_| {
|
||||||
|
Error::BadRequest("本地 embedding 模型当前不可用,请稍后重试".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let embeddings = guard
|
||||||
|
.embed(prepared, Some(EMBEDDING_BATCH_SIZE))
|
||||||
|
.map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?;
|
||||||
|
|
||||||
|
Ok(embeddings
|
||||||
|
.into_iter()
|
||||||
|
.map(|embedding| embedding.into_iter().map(f64::from).collect::<Vec<_>>())
|
||||||
|
.collect::<Vec<_>>())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| Error::BadRequest(format!("本地 embedding 任务执行失败: {error}")))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_message_content(value: &Value) -> Option<String> {
|
||||||
|
if let Some(content) = value
|
||||||
|
.get("choices")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|choices| choices.first())
|
||||||
|
.and_then(|choice| choice.get("message"))
|
||||||
|
.and_then(|message| message.get("content"))
|
||||||
|
{
|
||||||
|
if let Some(text) = content.as_str() {
|
||||||
|
return Some(text.trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parts) = content.as_array() {
|
||||||
|
let merged = parts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if !merged.trim().is_empty() {
|
||||||
|
return Some(merged.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_text_segments(parts: Vec<String>) -> Option<String> {
|
||||||
|
let merged = parts
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|part| {
|
||||||
|
let trimmed = part.trim().to_string();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(trimmed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if merged.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_response_output(value: &Value) -> Option<String> {
|
||||||
|
if let Some(text) = value.get("output_text").and_then(Value::as_str) {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let output_items = value.get("output").and_then(Value::as_array)?;
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
|
||||||
|
for item in output_items {
|
||||||
|
let Some(content_items) = item.get("content").and_then(Value::as_array) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
for content in content_items {
|
||||||
|
if let Some(text) = content.get("text").and_then(Value::as_str) {
|
||||||
|
segments.push(text.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(text) = content
|
||||||
|
.get("output_text")
|
||||||
|
.and_then(|output_text| output_text.get("text"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
{
|
||||||
|
segments.push(text.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_text_segments(segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_chat_prompt(question: &str, matches: &[ScoredChunk]) -> String {
|
||||||
|
let context_blocks = matches
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, item)| {
|
||||||
|
format!(
|
||||||
|
"[资料 {}]\n标题: {}\nSlug: {}\n相似度: {:.4}\n内容:\n{}",
|
||||||
|
index + 1,
|
||||||
|
item.row
|
||||||
|
.source_title
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or("未命名内容"),
|
||||||
|
item.row.source_slug,
|
||||||
|
item.score,
|
||||||
|
item.row.content
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"请仅根据下面提供的资料回答用户问题。\n\
|
||||||
|
如果资料不足以支撑结论,请直接说明“我在当前博客资料里没有找到足够信息”。\n\
|
||||||
|
回答要求:\n\
|
||||||
|
1. 使用中文。\n\
|
||||||
|
2. 使用 Markdown 输出,必要时用短列表或小标题,不要输出 HTML。\n\
|
||||||
|
3. 先给直接结论,再补充关键点,整体尽量精炼。\n\
|
||||||
|
4. 不要编造未在资料中出现的事实。\n\
|
||||||
|
5. 如果回答引用了具体资料,可自然地提及文章标题。\n\n\
|
||||||
|
用户问题:{question}\n\n\
|
||||||
|
可用资料:\n{context_blocks}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_sources(matches: &[ScoredChunk]) -> Vec<AiSource> {
|
||||||
|
matches
|
||||||
|
.iter()
|
||||||
|
.map(|item| AiSource {
|
||||||
|
slug: item.row.source_slug.clone(),
|
||||||
|
title: item
|
||||||
|
.row
|
||||||
|
.source_title
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or("未命名内容")
|
||||||
|
.to_string(),
|
||||||
|
excerpt: item
|
||||||
|
.row
|
||||||
|
.content_preview
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| preview_text(&item.row.content, 180).unwrap_or_default()),
|
||||||
|
score: (item.score * 10000.0).round() / 10000.0,
|
||||||
|
chunk_index: item.row.chunk_index,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool) -> Value {
|
||||||
|
if provider_uses_responses(&request.provider) {
|
||||||
|
json!({
|
||||||
|
"model": request.chat_model,
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "input_text",
|
||||||
|
"text": request.system_prompt
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "input_text",
|
||||||
|
"text": request.prompt
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reasoning": {
|
||||||
|
"effort": DEFAULT_REASONING_EFFORT
|
||||||
|
},
|
||||||
|
"max_output_tokens": 520,
|
||||||
|
"store": !DEFAULT_DISABLE_RESPONSE_STORAGE,
|
||||||
|
"stream": stream
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
json!({
|
||||||
|
"model": request.chat_model,
|
||||||
|
"temperature": 0.2,
|
||||||
|
"stream": stream,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": request.system_prompt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": request.prompt,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_provider_url(request: &AiProviderRequest) -> String {
|
||||||
|
let path = if provider_uses_responses(&request.provider) {
|
||||||
|
"/responses"
|
||||||
|
} else {
|
||||||
|
"/chat/completions"
|
||||||
|
};
|
||||||
|
|
||||||
|
build_endpoint(&request.api_base, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn extract_provider_text(value: &Value) -> Option<String> {
|
||||||
|
extract_response_output(value).or_else(|| extract_message_content(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_chat_answer(request: &AiProviderRequest) -> Result<String> {
|
||||||
|
let client = Client::new();
|
||||||
|
let response = request_json(
|
||||||
|
&client,
|
||||||
|
&build_provider_url(request),
|
||||||
|
&request.api_key,
|
||||||
|
build_provider_payload(request, false),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
extract_provider_text(&response).ok_or_else(|| {
|
||||||
|
Error::BadRequest("AI chat response did not contain readable content".to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn prepare_answer(ctx: &AppContext, question: &str) -> Result<PreparedAiAnswer> {
|
||||||
|
let trimmed_question = question.trim();
|
||||||
|
if trimmed_question.is_empty() {
|
||||||
|
return Err(Error::BadRequest("问题不能为空".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = load_runtime_settings(ctx, true).await?;
|
||||||
|
let (matches, indexed_chunks, last_indexed_at) =
|
||||||
|
retrieve_matches(ctx, &settings, trimmed_question).await?;
|
||||||
|
|
||||||
|
if matches.is_empty() {
|
||||||
|
return Ok(PreparedAiAnswer {
|
||||||
|
question: trimmed_question.to_string(),
|
||||||
|
provider_request: None,
|
||||||
|
immediate_answer: Some(
|
||||||
|
"我在当前博客资料里没有找到足够信息。你可以换个更具体的问题,或者先去后台重建一下 AI 索引。"
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
sources: Vec::new(),
|
||||||
|
indexed_chunks,
|
||||||
|
last_indexed_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let sources = build_sources(&matches);
|
||||||
|
let provider_request = match (settings.api_base.clone(), settings.api_key.clone()) {
|
||||||
|
(Some(api_base), Some(api_key)) => Some(AiProviderRequest {
|
||||||
|
provider: settings.provider.clone(),
|
||||||
|
api_base,
|
||||||
|
api_key,
|
||||||
|
chat_model: settings.chat_model.clone(),
|
||||||
|
system_prompt: settings.system_prompt.clone(),
|
||||||
|
prompt: build_chat_prompt(trimmed_question, &matches),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let immediate_answer = provider_request
|
||||||
|
.is_none()
|
||||||
|
.then(|| retrieval_only_answer(&matches));
|
||||||
|
|
||||||
|
Ok(PreparedAiAnswer {
|
||||||
|
question: trimmed_question.to_string(),
|
||||||
|
provider_request,
|
||||||
|
immediate_answer,
|
||||||
|
sources,
|
||||||
|
indexed_chunks,
|
||||||
|
last_indexed_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retrieval_only_answer(matches: &[ScoredChunk]) -> String {
|
||||||
|
let summary = matches
|
||||||
|
.iter()
|
||||||
|
.take(3)
|
||||||
|
.map(|item| {
|
||||||
|
let title = item
|
||||||
|
.row
|
||||||
|
.source_title
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or("未命名内容");
|
||||||
|
let excerpt = item
|
||||||
|
.row
|
||||||
|
.content_preview
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| preview_text(&item.row.content, 120).unwrap_or_default());
|
||||||
|
|
||||||
|
format!("《{title}》: {excerpt}")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"本地知识检索已经完成,但后台还没有配置聊天模型 API,所以我先返回最相关的资料摘要:\n{summary}\n\n\
|
||||||
|
如果你希望得到完整的自然语言回答,请在后台补上聊天模型的 API Base / API Key。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_runtime_settings(
|
||||||
|
ctx: &AppContext,
|
||||||
|
require_enabled: bool,
|
||||||
|
) -> Result<AiRuntimeSettings> {
|
||||||
|
let raw = site_settings::Entity::find()
|
||||||
|
.order_by_asc(site_settings::Column::Id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(Error::NotFound)?;
|
||||||
|
|
||||||
|
if require_enabled && !raw.ai_enabled.unwrap_or(false) {
|
||||||
|
return Err(Error::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AiRuntimeSettings {
|
||||||
|
provider: provider_name(raw.ai_provider.as_deref()),
|
||||||
|
api_base: trim_to_option(raw.ai_api_base.clone()),
|
||||||
|
api_key: trim_to_option(raw.ai_api_key.clone()),
|
||||||
|
chat_model: trim_to_option(raw.ai_chat_model.clone())
|
||||||
|
.unwrap_or_else(|| DEFAULT_CHAT_MODEL.to_string()),
|
||||||
|
system_prompt: trim_to_option(raw.ai_system_prompt.clone())
|
||||||
|
.unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string()),
|
||||||
|
top_k: raw
|
||||||
|
.ai_top_k
|
||||||
|
.map(|value| value.clamp(1, 12) as usize)
|
||||||
|
.unwrap_or(DEFAULT_TOP_K),
|
||||||
|
chunk_size: raw
|
||||||
|
.ai_chunk_size
|
||||||
|
.map(|value| value.clamp(400, 4000) as usize)
|
||||||
|
.unwrap_or(DEFAULT_CHUNK_SIZE),
|
||||||
|
raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_indexed_at(
|
||||||
|
ctx: &AppContext,
|
||||||
|
settings: &site_settings::Model,
|
||||||
|
) -> Result<DateTime<Utc>> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut model = settings.clone().into_active_model();
|
||||||
|
model.ai_last_indexed_at = Set(Some(now.into()));
|
||||||
|
let _ = model.update(&ctx.db).await?;
|
||||||
|
Ok(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn retrieve_matches(
|
||||||
|
ctx: &AppContext,
|
||||||
|
settings: &AiRuntimeSettings,
|
||||||
|
question: &str,
|
||||||
|
) -> Result<(Vec<ScoredChunk>, usize, Option<DateTime<Utc>>)> {
|
||||||
|
let mut indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
|
||||||
|
let mut last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);
|
||||||
|
|
||||||
|
if indexed_chunks == 0 {
|
||||||
|
let summary = rebuild_index(ctx).await?;
|
||||||
|
indexed_chunks = summary.indexed_chunks;
|
||||||
|
last_indexed_at = summary.last_indexed_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
if indexed_chunks == 0 {
|
||||||
|
return Ok((Vec::new(), 0, last_indexed_at));
|
||||||
|
}
|
||||||
|
|
||||||
|
let question_embedding =
|
||||||
|
embed_texts_locally(vec![question.trim().to_string()], EmbeddingKind::Query)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let query_vector = vector_literal(&question_embedding)?;
|
||||||
|
|
||||||
|
let statement = Statement::from_sql_and_values(
|
||||||
|
DbBackend::Postgres,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
source_slug,
|
||||||
|
source_title,
|
||||||
|
chunk_index,
|
||||||
|
content,
|
||||||
|
content_preview,
|
||||||
|
word_count,
|
||||||
|
(1 - (embedding <=> $1::vector))::float8 AS score
|
||||||
|
FROM ai_chunks
|
||||||
|
WHERE embedding IS NOT NULL
|
||||||
|
ORDER BY embedding <=> $1::vector
|
||||||
|
LIMIT $2
|
||||||
|
"#,
|
||||||
|
[query_vector.into(), (settings.top_k as i64).into()],
|
||||||
|
);
|
||||||
|
|
||||||
|
let matches = SimilarChunkRow::find_by_statement(statement)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| ScoredChunk {
|
||||||
|
score: row.score,
|
||||||
|
row: ai_chunks::Model {
|
||||||
|
created_at: Utc::now().into(),
|
||||||
|
updated_at: Utc::now().into(),
|
||||||
|
id: 0,
|
||||||
|
source_slug: row.source_slug,
|
||||||
|
source_title: row.source_title,
|
||||||
|
source_path: None,
|
||||||
|
source_type: "post".to_string(),
|
||||||
|
chunk_index: row.chunk_index,
|
||||||
|
content: row.content,
|
||||||
|
content_preview: row.content_preview,
|
||||||
|
embedding: None,
|
||||||
|
word_count: row.word_count,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Ok((matches, indexed_chunks, last_indexed_at))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rebuild_index(ctx: &AppContext) -> Result<AiIndexSummary> {
|
||||||
|
let settings = load_runtime_settings(ctx, false).await?;
|
||||||
|
let posts = content::sync_markdown_posts(ctx).await?;
|
||||||
|
let chunk_drafts = build_chunks(&posts, settings.chunk_size);
|
||||||
|
let embeddings = if chunk_drafts.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
embed_texts_locally(
|
||||||
|
chunk_drafts
|
||||||
|
.iter()
|
||||||
|
.map(|chunk| chunk.content.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
EmbeddingKind::Passage,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.db
|
||||||
|
.execute(Statement::from_string(
|
||||||
|
DbBackend::Postgres,
|
||||||
|
"TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (draft, embedding) in chunk_drafts.iter().zip(embeddings.into_iter()) {
|
||||||
|
let embedding_literal = vector_literal(&embedding)?;
|
||||||
|
let statement = Statement::from_sql_and_values(
|
||||||
|
DbBackend::Postgres,
|
||||||
|
r#"
|
||||||
|
INSERT INTO ai_chunks (
|
||||||
|
source_slug,
|
||||||
|
source_title,
|
||||||
|
source_path,
|
||||||
|
source_type,
|
||||||
|
chunk_index,
|
||||||
|
content,
|
||||||
|
content_preview,
|
||||||
|
embedding,
|
||||||
|
word_count
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8::vector, $9
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
vec![
|
||||||
|
draft.source_slug.clone().into(),
|
||||||
|
draft.source_title.clone().into(),
|
||||||
|
draft.source_path.clone().into(),
|
||||||
|
draft.source_type.clone().into(),
|
||||||
|
draft.chunk_index.into(),
|
||||||
|
draft.content.clone().into(),
|
||||||
|
draft.content_preview.clone().into(),
|
||||||
|
embedding_literal.into(),
|
||||||
|
draft.word_count.into(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
ctx.db.execute(statement).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_indexed_at = update_indexed_at(ctx, &settings.raw).await?;
|
||||||
|
|
||||||
|
Ok(AiIndexSummary {
|
||||||
|
indexed_chunks: chunk_drafts.len(),
|
||||||
|
last_indexed_at: Some(last_indexed_at),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn answer_question(ctx: &AppContext, question: &str) -> Result<AiAnswer> {
|
||||||
|
let prepared = prepare_answer(ctx, question).await?;
|
||||||
|
let answer = if let Some(immediate_answer) = prepared.immediate_answer.clone() {
|
||||||
|
immediate_answer
|
||||||
|
} else {
|
||||||
|
let request = prepared.provider_request.as_ref().ok_or_else(|| {
|
||||||
|
Error::BadRequest("AI provider request was not prepared".to_string())
|
||||||
|
})?;
|
||||||
|
request_chat_answer(request).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AiAnswer {
|
||||||
|
answer,
|
||||||
|
sources: prepared.sources,
|
||||||
|
indexed_chunks: prepared.indexed_chunks,
|
||||||
|
last_indexed_at: prepared.last_indexed_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provider_name(value: Option<&str>) -> String {
|
||||||
|
trim_to_option(value.map(ToString::to_string))
|
||||||
|
.unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_api_base() -> &'static str {
|
||||||
|
DEFAULT_AI_API_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_api_key() -> &'static str {
|
||||||
|
DEFAULT_AI_API_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_chat_model() -> &'static str {
|
||||||
|
DEFAULT_CHAT_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn local_embedding_label() -> &'static str {
|
||||||
|
LOCAL_EMBEDDING_MODEL_LABEL
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
|
ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter,
|
||||||
|
QueryOrder, Set,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::models::_entities::{categories, posts, tags};
|
use crate::models::_entities::{categories, comments, posts, tags};
|
||||||
|
|
||||||
pub const MARKDOWN_POSTS_DIR: &str = "content/posts";
|
pub const MARKDOWN_POSTS_DIR: &str = "content/posts";
|
||||||
const FIXTURE_POSTS_FILE: &str = "src/fixtures/posts.yaml";
|
const FIXTURE_POSTS_FILE: &str = "src/fixtures/posts.yaml";
|
||||||
@@ -120,6 +121,19 @@ fn slugify(value: &str) -> String {
|
|||||||
slug.trim_matches('-').to_string()
|
slug.trim_matches('-').to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalized_match_key(value: &str) -> String {
|
||||||
|
value.trim().to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same_text(left: &str, right: &str) -> bool {
|
||||||
|
normalized_match_key(left) == normalized_match_key(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_matches_any(value: &str, keys: &[String]) -> bool {
|
||||||
|
let current = normalized_match_key(value);
|
||||||
|
!current.is_empty() && keys.iter().any(|key| current == *key)
|
||||||
|
}
|
||||||
|
|
||||||
fn excerpt_from_content(content: &str) -> Option<String> {
|
fn excerpt_from_content(content: &str) -> Option<String> {
|
||||||
let mut in_code_block = false;
|
let mut in_code_block = false;
|
||||||
|
|
||||||
@@ -135,7 +149,11 @@ fn excerpt_from_content(content: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let excerpt = trimmed.chars().take(180).collect::<String>();
|
let excerpt = trimmed.chars().take(180).collect::<String>();
|
||||||
return if excerpt.is_empty() { None } else { Some(excerpt) };
|
return if excerpt.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(excerpt)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
@@ -188,7 +206,8 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result<
|
|||||||
let title = trim_to_option(frontmatter.title.clone())
|
let title = trim_to_option(frontmatter.title.clone())
|
||||||
.or_else(|| title_from_content(&content))
|
.or_else(|| title_from_content(&content))
|
||||||
.unwrap_or_else(|| slug.clone());
|
.unwrap_or_else(|| slug.clone());
|
||||||
let description = trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content));
|
let description =
|
||||||
|
trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content));
|
||||||
let category = trim_to_option(frontmatter.category.clone());
|
let category = trim_to_option(frontmatter.category.clone());
|
||||||
let tags = frontmatter
|
let tags = frontmatter
|
||||||
.tags
|
.tags
|
||||||
@@ -205,7 +224,8 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result<
|
|||||||
content: content.trim_start_matches('\n').to_string(),
|
content: content.trim_start_matches('\n').to_string(),
|
||||||
category,
|
category,
|
||||||
tags,
|
tags,
|
||||||
post_type: trim_to_option(frontmatter.post_type.clone()).unwrap_or_else(|| "article".to_string()),
|
post_type: trim_to_option(frontmatter.post_type.clone())
|
||||||
|
.unwrap_or_else(|| "article".to_string()),
|
||||||
image: trim_to_option(frontmatter.image.clone()),
|
image: trim_to_option(frontmatter.image.clone()),
|
||||||
pinned: frontmatter.pinned.unwrap_or(false),
|
pinned: frontmatter.pinned.unwrap_or(false),
|
||||||
published: frontmatter.published.unwrap_or(true),
|
published: frontmatter.published.unwrap_or(true),
|
||||||
@@ -216,7 +236,12 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result<
|
|||||||
fn build_markdown_document(post: &MarkdownPost) -> String {
|
fn build_markdown_document(post: &MarkdownPost) -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"---".to_string(),
|
"---".to_string(),
|
||||||
format!("title: {}", serde_yaml::to_string(&post.title).unwrap_or_else(|_| format!("{:?}", post.title)).trim()),
|
format!(
|
||||||
|
"title: {}",
|
||||||
|
serde_yaml::to_string(&post.title)
|
||||||
|
.unwrap_or_else(|_| format!("{:?}", post.title))
|
||||||
|
.trim()
|
||||||
|
),
|
||||||
format!("slug: {}", post.slug),
|
format!("slug: {}", post.slug),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -284,10 +309,16 @@ fn ensure_markdown_posts_bootstrapped() -> Result<()> {
|
|||||||
image: None,
|
image: None,
|
||||||
pinned: fixture.pinned.unwrap_or(false),
|
pinned: fixture.pinned.unwrap_or(false),
|
||||||
published: fixture.published.unwrap_or(true),
|
published: fixture.published.unwrap_or(true),
|
||||||
file_path: markdown_post_path(&fixture.slug).to_string_lossy().to_string(),
|
file_path: markdown_post_path(&fixture.slug)
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
fs::write(markdown_post_path(&fixture.slug), build_markdown_document(&post)).map_err(io_error)?;
|
fs::write(
|
||||||
|
markdown_post_path(&fixture.slug),
|
||||||
|
build_markdown_document(&post),
|
||||||
|
)
|
||||||
|
.map_err(io_error)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -312,14 +343,19 @@ async fn sync_tags_from_posts(ctx: &AppContext, posts: &[MarkdownPost]) -> Resul
|
|||||||
for post in posts {
|
for post in posts {
|
||||||
for tag_name in &post.tags {
|
for tag_name in &post.tags {
|
||||||
let slug = slugify(tag_name);
|
let slug = slugify(tag_name);
|
||||||
|
let trimmed = tag_name.trim();
|
||||||
let existing = tags::Entity::find()
|
let existing = tags::Entity::find()
|
||||||
.filter(tags::Column::Slug.eq(&slug))
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(tags::Column::Slug.eq(&slug))
|
||||||
|
.add(tags::Column::Name.eq(trimmed)),
|
||||||
|
)
|
||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if existing.is_none() {
|
if existing.is_none() {
|
||||||
let item = tags::ActiveModel {
|
let item = tags::ActiveModel {
|
||||||
name: Set(Some(tag_name.clone())),
|
name: Set(Some(trimmed.to_string())),
|
||||||
slug: Set(slug),
|
slug: Set(slug),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -339,12 +375,21 @@ async fn ensure_category(ctx: &AppContext, raw_name: &str) -> Result<Option<Stri
|
|||||||
|
|
||||||
let slug = slugify(name);
|
let slug = slugify(name);
|
||||||
let existing = categories::Entity::find()
|
let existing = categories::Entity::find()
|
||||||
.filter(categories::Column::Slug.eq(&slug))
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(categories::Column::Slug.eq(&slug))
|
||||||
|
.add(categories::Column::Name.eq(name)),
|
||||||
|
)
|
||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(category) = existing {
|
if let Some(category) = existing {
|
||||||
if let Some(existing_name) = category.name.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
if let Some(existing_name) = category
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
return Ok(Some(existing_name.to_string()));
|
return Ok(Some(existing_name.to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,12 +426,21 @@ async fn canonicalize_tags(ctx: &AppContext, raw_tags: &[String]) -> Result<Vec<
|
|||||||
}
|
}
|
||||||
|
|
||||||
let existing = tags::Entity::find()
|
let existing = tags::Entity::find()
|
||||||
.filter(tags::Column::Slug.eq(&slug))
|
.filter(
|
||||||
|
Condition::any()
|
||||||
|
.add(tags::Column::Slug.eq(&slug))
|
||||||
|
.add(tags::Column::Name.eq(trimmed)),
|
||||||
|
)
|
||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let canonical_name = if let Some(tag) = existing {
|
let canonical_name = if let Some(tag) = existing {
|
||||||
if let Some(existing_name) = tag.name.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
if let Some(existing_name) = tag
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
existing_name.to_string()
|
existing_name.to_string()
|
||||||
} else {
|
} else {
|
||||||
let mut tag_model = tag.into_active_model();
|
let mut tag_model = tag.into_active_model();
|
||||||
@@ -415,6 +469,132 @@ async fn canonicalize_tags(ctx: &AppContext, raw_tags: &[String]) -> Result<Vec<
|
|||||||
Ok(canonical_tags)
|
Ok(canonical_tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_markdown_post_to_disk(post: &MarkdownPost) -> Result<()> {
|
||||||
|
fs::write(markdown_post_path(&post.slug), build_markdown_document(post)).map_err(io_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rewrite_category_references(
|
||||||
|
current_name: Option<&str>,
|
||||||
|
current_slug: &str,
|
||||||
|
next_name: Option<&str>,
|
||||||
|
) -> Result<usize> {
|
||||||
|
ensure_markdown_posts_bootstrapped()?;
|
||||||
|
|
||||||
|
let mut match_keys = Vec::new();
|
||||||
|
if let Some(name) = current_name {
|
||||||
|
let normalized = normalized_match_key(name);
|
||||||
|
if !normalized.is_empty() {
|
||||||
|
match_keys.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized_slug = normalized_match_key(current_slug);
|
||||||
|
if !normalized_slug.is_empty() {
|
||||||
|
match_keys.push(normalized_slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
if match_keys.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_category = next_name
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string);
|
||||||
|
let mut changed = 0_usize;
|
||||||
|
let mut posts = load_markdown_posts_from_disk()?;
|
||||||
|
|
||||||
|
for post in &mut posts {
|
||||||
|
let Some(category) = post.category.as_deref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !text_matches_any(category, &match_keys) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match &next_category {
|
||||||
|
Some(updated_name) if same_text(category, updated_name) => {}
|
||||||
|
Some(updated_name) => {
|
||||||
|
post.category = Some(updated_name.clone());
|
||||||
|
write_markdown_post_to_disk(post)?;
|
||||||
|
changed += 1;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
post.category = None;
|
||||||
|
write_markdown_post_to_disk(post)?;
|
||||||
|
changed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rewrite_tag_references(
|
||||||
|
current_name: Option<&str>,
|
||||||
|
current_slug: &str,
|
||||||
|
next_name: Option<&str>,
|
||||||
|
) -> Result<usize> {
|
||||||
|
ensure_markdown_posts_bootstrapped()?;
|
||||||
|
|
||||||
|
let mut match_keys = Vec::new();
|
||||||
|
if let Some(name) = current_name {
|
||||||
|
let normalized = normalized_match_key(name);
|
||||||
|
if !normalized.is_empty() {
|
||||||
|
match_keys.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized_slug = normalized_match_key(current_slug);
|
||||||
|
if !normalized_slug.is_empty() {
|
||||||
|
match_keys.push(normalized_slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
if match_keys.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_tag = next_name
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string);
|
||||||
|
let mut changed = 0_usize;
|
||||||
|
let mut posts = load_markdown_posts_from_disk()?;
|
||||||
|
|
||||||
|
for post in &mut posts {
|
||||||
|
let mut updated_tags = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut post_changed = false;
|
||||||
|
|
||||||
|
for tag in &post.tags {
|
||||||
|
if text_matches_any(tag, &match_keys) {
|
||||||
|
post_changed = true;
|
||||||
|
if let Some(next_tag_name) = &next_tag {
|
||||||
|
let normalized = normalized_match_key(next_tag_name);
|
||||||
|
if seen.insert(normalized) {
|
||||||
|
updated_tags.push(next_tag_name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = normalized_match_key(tag);
|
||||||
|
if seen.insert(normalized) {
|
||||||
|
updated_tags.push(tag.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if post_changed {
|
||||||
|
post.tags = updated_tags;
|
||||||
|
write_markdown_post_to_disk(post)?;
|
||||||
|
changed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
async fn dedupe_tags(ctx: &AppContext) -> Result<()> {
|
async fn dedupe_tags(ctx: &AppContext) -> Result<()> {
|
||||||
let existing_tags = tags::Entity::find()
|
let existing_tags = tags::Entity::find()
|
||||||
.order_by_asc(tags::Column::Id)
|
.order_by_asc(tags::Column::Id)
|
||||||
@@ -425,10 +605,7 @@ async fn dedupe_tags(ctx: &AppContext) -> Result<()> {
|
|||||||
|
|
||||||
for tag in existing_tags {
|
for tag in existing_tags {
|
||||||
let key = if tag.slug.trim().is_empty() {
|
let key = if tag.slug.trim().is_empty() {
|
||||||
tag.name
|
tag.name.as_deref().map(slugify).unwrap_or_default()
|
||||||
.as_deref()
|
|
||||||
.map(slugify)
|
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
} else {
|
||||||
slugify(&tag.slug)
|
slugify(&tag.slug)
|
||||||
};
|
};
|
||||||
@@ -453,11 +630,7 @@ async fn dedupe_categories(ctx: &AppContext) -> Result<()> {
|
|||||||
|
|
||||||
for category in existing_categories {
|
for category in existing_categories {
|
||||||
let key = if category.slug.trim().is_empty() {
|
let key = if category.slug.trim().is_empty() {
|
||||||
category
|
category.name.as_deref().map(slugify).unwrap_or_default()
|
||||||
.name
|
|
||||||
.as_deref()
|
|
||||||
.map(slugify)
|
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
} else {
|
||||||
slugify(&category.slug)
|
slugify(&category.slug)
|
||||||
};
|
};
|
||||||
@@ -474,6 +647,28 @@ async fn dedupe_categories(ctx: &AppContext) -> Result<()> {
|
|||||||
|
|
||||||
pub async fn sync_markdown_posts(ctx: &AppContext) -> Result<Vec<MarkdownPost>> {
|
pub async fn sync_markdown_posts(ctx: &AppContext) -> Result<Vec<MarkdownPost>> {
|
||||||
let markdown_posts = load_markdown_posts_from_disk()?;
|
let markdown_posts = load_markdown_posts_from_disk()?;
|
||||||
|
let markdown_slugs = markdown_posts
|
||||||
|
.iter()
|
||||||
|
.map(|post| post.slug.clone())
|
||||||
|
.collect::<std::collections::HashSet<_>>();
|
||||||
|
let existing_posts = posts::Entity::find().all(&ctx.db).await?;
|
||||||
|
|
||||||
|
for stale_post in existing_posts
|
||||||
|
.into_iter()
|
||||||
|
.filter(|post| !markdown_slugs.contains(&post.slug))
|
||||||
|
{
|
||||||
|
let stale_slug = stale_post.slug.clone();
|
||||||
|
let related_comments = comments::Entity::find()
|
||||||
|
.filter(comments::Column::PostSlug.eq(&stale_slug))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for comment in related_comments {
|
||||||
|
let _ = comment.delete(&ctx.db).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = stale_post.delete(&ctx.db).await;
|
||||||
|
}
|
||||||
|
|
||||||
for post in &markdown_posts {
|
for post in &markdown_posts {
|
||||||
let canonical_category = match post.category.as_deref() {
|
let canonical_category = match post.category.as_deref() {
|
||||||
@@ -545,6 +740,18 @@ pub async fn write_markdown_document(
|
|||||||
Ok(updated)
|
Ok(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_markdown_post(ctx: &AppContext, slug: &str) -> Result<()> {
|
||||||
|
ensure_markdown_posts_bootstrapped()?;
|
||||||
|
let path = markdown_post_path(slug);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(Error::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_file(&path).map_err(io_error)?;
|
||||||
|
sync_markdown_posts(ctx).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn create_markdown_post(
|
pub async fn create_markdown_post(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
draft: MarkdownPostDraft,
|
draft: MarkdownPostDraft,
|
||||||
@@ -594,9 +801,16 @@ pub async fn create_markdown_post(
|
|||||||
file_path: markdown_post_path(&slug).to_string_lossy().to_string(),
|
file_path: markdown_post_path(&slug).to_string_lossy().to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
fs::write(markdown_post_path(&slug), build_markdown_document(&post)).map_err(io_error)?;
|
let path = markdown_post_path(&slug);
|
||||||
|
if path.exists() {
|
||||||
|
return Err(Error::BadRequest(format!(
|
||||||
|
"markdown post already exists for slug: {slug}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(&path, build_markdown_document(&post)).map_err(io_error)?;
|
||||||
sync_markdown_posts(ctx).await?;
|
sync_markdown_posts(ctx).await?;
|
||||||
parse_markdown_post(&markdown_post_path(&slug))
|
parse_markdown_post(&path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn import_markdown_documents(
|
pub async fn import_markdown_documents(
|
||||||
@@ -635,7 +849,8 @@ pub async fn import_markdown_documents(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::write(markdown_post_path(&slug), normalize_newlines(&file.content)).map_err(io_error)?;
|
fs::write(markdown_post_path(&slug), normalize_newlines(&file.content))
|
||||||
|
.map_err(io_error)?;
|
||||||
imported_slugs.push(slug);
|
imported_slugs.push(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
pub mod ai;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
|
|||||||
26
dev.ps1
26
dev.ps1
@@ -1,6 +1,8 @@
|
|||||||
param(
|
param(
|
||||||
[switch]$FrontendOnly,
|
[switch]$FrontendOnly,
|
||||||
[switch]$BackendOnly,
|
[switch]$BackendOnly,
|
||||||
|
[switch]$McpOnly,
|
||||||
|
[switch]$WithMcp,
|
||||||
[string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
|
[string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -9,9 +11,10 @@ $ErrorActionPreference = "Stop"
|
|||||||
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
$frontendScript = Join-Path $repoRoot "start-frontend.ps1"
|
$frontendScript = Join-Path $repoRoot "start-frontend.ps1"
|
||||||
$backendScript = Join-Path $repoRoot "start-backend.ps1"
|
$backendScript = Join-Path $repoRoot "start-backend.ps1"
|
||||||
|
$mcpScript = Join-Path $repoRoot "start-mcp.ps1"
|
||||||
|
|
||||||
if ($FrontendOnly -and $BackendOnly) {
|
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||||
throw "Use either -FrontendOnly or -BackendOnly, not both."
|
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly."
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($FrontendOnly) {
|
if ($FrontendOnly) {
|
||||||
@@ -24,7 +27,13 @@ if ($BackendOnly) {
|
|||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "[monorepo] Starting frontend and backend in separate PowerShell windows..." -ForegroundColor Cyan
|
if ($McpOnly) {
|
||||||
|
& $mcpScript
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
$services = if ($WithMcp) { "frontend, backend, and MCP" } else { "frontend and backend" }
|
||||||
|
Write-Host "[monorepo] Starting $services in separate PowerShell windows..." -ForegroundColor Cyan
|
||||||
|
|
||||||
Start-Process powershell -ArgumentList @(
|
Start-Process powershell -ArgumentList @(
|
||||||
"-NoExit",
|
"-NoExit",
|
||||||
@@ -39,4 +48,13 @@ Start-Process powershell -ArgumentList @(
|
|||||||
"-DatabaseUrl", $DatabaseUrl
|
"-DatabaseUrl", $DatabaseUrl
|
||||||
)
|
)
|
||||||
|
|
||||||
Write-Host "[monorepo] Frontend window and backend window started." -ForegroundColor Green
|
if ($WithMcp) {
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
"-NoExit",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", $mcpScript
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
$servicesStarted = if ($WithMcp) { "Frontend, backend, and MCP windows started." } else { "Frontend window and backend window started." }
|
||||||
|
Write-Host "[monorepo] $servicesStarted" -ForegroundColor Green
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
(function() {
|
(function() {
|
||||||
|
const t = window.__termiTranslate;
|
||||||
|
|
||||||
function initCodeCopy() {
|
function initCodeCopy() {
|
||||||
const codeBlocks = document.querySelectorAll('pre code');
|
const codeBlocks = document.querySelectorAll('pre code');
|
||||||
|
|
||||||
@@ -17,24 +19,24 @@
|
|||||||
// Create copy button
|
// Create copy button
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.className = 'absolute top-2 right-2 px-2 py-1 text-xs rounded bg-[var(--terminal-bg)] text-[var(--text-secondary)] opacity-0 group-hover:opacity-100 transition-opacity border border-[var(--border-color)] hover:border-[var(--primary)] hover:text-[var(--primary)]';
|
button.className = 'absolute top-2 right-2 px-2 py-1 text-xs rounded bg-[var(--terminal-bg)] text-[var(--text-secondary)] opacity-0 group-hover:opacity-100 transition-opacity border border-[var(--border-color)] hover:border-[var(--primary)] hover:text-[var(--primary)]';
|
||||||
button.innerHTML = '<i class="fas fa-copy mr-1"></i>复制';
|
button.innerHTML = `<i class="fas fa-copy mr-1"></i>${t('codeCopy.copy')}`;
|
||||||
|
|
||||||
button.addEventListener('click', async () => {
|
button.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(code.textContent || '');
|
await navigator.clipboard.writeText(code.textContent || '');
|
||||||
button.innerHTML = '<i class="fas fa-check mr-1"></i>已复制';
|
button.innerHTML = `<i class="fas fa-check mr-1"></i>${t('codeCopy.copied')}`;
|
||||||
button.classList.add('text-[var(--success)]');
|
button.classList.add('text-[var(--success)]');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
button.innerHTML = '<i class="fas fa-copy mr-1"></i>复制';
|
button.innerHTML = `<i class="fas fa-copy mr-1"></i>${t('codeCopy.copy')}`;
|
||||||
button.classList.remove('text-[var(--success)]');
|
button.classList.remove('text-[var(--success)]');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
button.innerHTML = '<i class="fas fa-times mr-1"></i>失败';
|
button.innerHTML = `<i class="fas fa-times mr-1"></i>${t('codeCopy.failed')}`;
|
||||||
button.classList.add('text-[var(--error)]');
|
button.classList.add('text-[var(--error)]');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
button.innerHTML = '<i class="fas fa-copy mr-1"></i>复制';
|
button.innerHTML = `<i class="fas fa-copy mr-1"></i>${t('codeCopy.copy')}`;
|
||||||
button.classList.remove('text-[var(--error)]');
|
button.classList.remove('text-[var(--error)]');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { API_BASE_URL, apiClient } from '../lib/api/client';
|
import { API_BASE_URL, apiClient } from '../lib/api/client';
|
||||||
|
import { getI18n } from '../lib/i18n';
|
||||||
import type { Comment } from '../lib/api/client';
|
import type { Comment } from '../lib/api/client';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,14 +9,15 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { postSlug, class: className = '' } = Astro.props;
|
const { postSlug, class: className = '' } = Astro.props;
|
||||||
|
const { locale, t } = getI18n(Astro);
|
||||||
|
|
||||||
let comments: Comment[] = [];
|
let comments: Comment[] = [];
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
comments = await apiClient.getComments(postSlug, { approved: true });
|
comments = await apiClient.getComments(postSlug, { approved: true, scope: 'article' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : '加载评论失败';
|
error = e instanceof Error ? e.message : t('comments.loadFailed');
|
||||||
console.error('Failed to fetch comments:', e);
|
console.error('Failed to fetch comments:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +27,11 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
const diff = now.getTime() - date.getTime();
|
const diff = now.getTime() - date.getTime();
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
if (days === 0) return '今天';
|
if (days === 0) return t('comments.today');
|
||||||
if (days === 1) return '昨天';
|
if (days === 1) return t('comments.yesterday');
|
||||||
if (days < 7) return `${days} 天前`;
|
if (days < 7) return t('comments.daysAgo', { count: days });
|
||||||
if (days < 30) return `${Math.floor(days / 7)} 周前`;
|
if (days < 30) return t('comments.weeksAgo', { count: Math.floor(days / 7) });
|
||||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
return date.toLocaleDateString(locale, { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -45,9 +47,9 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
<i class="fas fa-comments"></i>
|
<i class="fas fa-comments"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold text-[var(--title-color)]">评论终端</h3>
|
<h3 class="text-xl font-semibold text-[var(--title-color)]">{t('comments.title')}</h3>
|
||||||
<p class="text-sm text-[var(--text-secondary)]">
|
<p class="text-sm text-[var(--text-secondary)]">
|
||||||
当前缓冲区共有 {comments.length} 条已展示评论,新的留言提交后会进入审核队列。
|
{t('comments.description', { count: comments.length })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +57,7 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
|
|
||||||
<button type="button" id="toggle-comment-form" class="terminal-action-button terminal-action-button-primary">
|
<button type="button" id="toggle-comment-form" class="terminal-action-button terminal-action-button-primary">
|
||||||
<i class="fas fa-pen"></i>
|
<i class="fas fa-pen"></i>
|
||||||
<span>write comment</span>
|
<span>{t('comments.writeComment')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="terminal-form-label">
|
<label class="terminal-form-label">
|
||||||
nickname <span class="text-[var(--primary)]">*</span>
|
{t('comments.nickname')} <span class="text-[var(--primary)]">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -76,7 +78,7 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="terminal-form-label">
|
<label class="terminal-form-label">
|
||||||
email <span class="text-[var(--text-tertiary)] normal-case tracking-normal">(optional)</span>
|
{t('comments.email')} <span class="text-[var(--text-tertiary)] normal-case tracking-normal">({t('common.optional')})</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -89,37 +91,37 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="terminal-form-label">
|
<label class="terminal-form-label">
|
||||||
message <span class="text-[var(--primary)]">*</span>
|
{t('comments.message')} <span class="text-[var(--primary)]">*</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="content"
|
name="content"
|
||||||
required
|
required
|
||||||
rows="6"
|
rows="6"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
placeholder="$ echo 'Leave your thoughts here...'"
|
placeholder={t('comments.messagePlaceholder')}
|
||||||
class="terminal-form-textarea resize-y"
|
class="terminal-form-textarea resize-y"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">max 500 chars</p>
|
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">{t('comments.maxChars')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="replying-to" class="terminal-panel-muted hidden items-center justify-between gap-3 py-3">
|
<div id="replying-to" class="terminal-panel-muted hidden items-center justify-between gap-3 py-3">
|
||||||
<span class="text-sm text-[var(--text-secondary)]">
|
<span class="text-sm text-[var(--text-secondary)]">
|
||||||
reply -> <span id="reply-target" class="font-medium text-[var(--primary)]"></span>
|
{t('common.reply')} -> <span id="reply-target" class="font-medium text-[var(--primary)]"></span>
|
||||||
</span>
|
</span>
|
||||||
<button type="button" id="cancel-reply" class="terminal-action-button">
|
<button type="button" id="cancel-reply" class="terminal-action-button">
|
||||||
<i class="fas fa-xmark"></i>
|
<i class="fas fa-xmark"></i>
|
||||||
<span>cancel reply</span>
|
<span>{t('comments.cancelReply')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<button type="submit" class="terminal-action-button terminal-action-button-primary">
|
<button type="submit" class="terminal-action-button terminal-action-button-primary">
|
||||||
<i class="fas fa-paper-plane"></i>
|
<i class="fas fa-paper-plane"></i>
|
||||||
<span>submit</span>
|
<span>{t('common.submit')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="cancel-comment" class="terminal-action-button">
|
<button type="button" id="cancel-comment" class="terminal-action-button">
|
||||||
<i class="fas fa-ban"></i>
|
<i class="fas fa-ban"></i>
|
||||||
<span>close</span>
|
<span>{t('common.close')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -138,9 +140,9 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
<span class="terminal-section-icon">
|
<span class="terminal-section-icon">
|
||||||
<i class="fas fa-comment-slash"></i>
|
<i class="fas fa-comment-slash"></i>
|
||||||
</span>
|
</span>
|
||||||
<h4 class="text-lg font-semibold text-[var(--title-color)]">暂无评论</h4>
|
<h4 class="text-lg font-semibold text-[var(--title-color)]">{t('comments.emptyTitle')}</h4>
|
||||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
当前还没有留言。可以打开上面的输入面板,作为第一个在这个终端缓冲区里发言的人。
|
{t('comments.emptyDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +162,7 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
|
|
||||||
<div class="min-w-0 flex-1 space-y-3">
|
<div class="min-w-0 flex-1 space-y-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="font-semibold text-[var(--title-color)]">{comment.author || '匿名'}</span>
|
<span class="font-semibold text-[var(--title-color)]">{comment.author || t('comments.anonymous')}</span>
|
||||||
<span class="terminal-chip px-2.5 py-1 text-xs">
|
<span class="terminal-chip px-2.5 py-1 text-xs">
|
||||||
<i class="far fa-clock text-[var(--primary)]"></i>
|
<i class="far fa-clock text-[var(--primary)]"></i>
|
||||||
{formatCommentDate(comment.created_at)}
|
{formatCommentDate(comment.created_at)}
|
||||||
@@ -177,14 +179,14 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
data-id={comment.id}
|
data-id={comment.id}
|
||||||
>
|
>
|
||||||
<i class="fas fa-reply"></i>
|
<i class="fas fa-reply"></i>
|
||||||
<span>reply</span>
|
<span>{t('common.reply')}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="like-btn terminal-action-button px-3 py-2 text-xs"
|
class="like-btn terminal-action-button px-3 py-2 text-xs"
|
||||||
>
|
>
|
||||||
<i class="far fa-thumbs-up"></i>
|
<i class="far fa-thumbs-up"></i>
|
||||||
<span>like</span>
|
<span>{t('common.like')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,6 +198,7 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const t = window.__termiTranslate;
|
||||||
const wrapper = document.querySelector('.terminal-comments');
|
const wrapper = document.querySelector('.terminal-comments');
|
||||||
const toggleBtn = document.getElementById('toggle-comment-form');
|
const toggleBtn = document.getElementById('toggle-comment-form');
|
||||||
const formContainer = document.getElementById('comment-form-container');
|
const formContainer = document.getElementById('comment-form-container');
|
||||||
@@ -268,7 +271,7 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
if (replyingTo && replyTarget) {
|
if (replyingTo && replyTarget) {
|
||||||
replyingTo.classList.remove('hidden');
|
replyingTo.classList.remove('hidden');
|
||||||
replyingTo.classList.add('flex');
|
replyingTo.classList.add('flex');
|
||||||
replyTarget.textContent = author || '匿名';
|
replyTarget.textContent = author || t('comments.anonymous');
|
||||||
replyingTo.setAttribute('data-reply-to', commentId || '');
|
replyingTo.setAttribute('data-reply-to', commentId || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +292,7 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
const replyToId = replyingTo?.getAttribute('data-reply-to');
|
const replyToId = replyingTo?.getAttribute('data-reply-to');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showMessage('正在提交评论...', 'info');
|
showMessage(t('comments.submitting'), 'info');
|
||||||
|
|
||||||
const response = await fetch(`${apiBase}/comments`, {
|
const response = await fetch(`${apiBase}/comments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -301,7 +304,8 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
nickname: formData.get('nickname'),
|
nickname: formData.get('nickname'),
|
||||||
email: formData.get('email'),
|
email: formData.get('email'),
|
||||||
content: formData.get('content'),
|
content: formData.get('content'),
|
||||||
replyTo: replyToId || null,
|
scope: 'article',
|
||||||
|
replyToCommentId: replyToId ? Number(replyToId) : null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,9 +317,9 @@ function formatCommentDate(dateStr: string): string {
|
|||||||
replyingTo?.classList.remove('flex');
|
replyingTo?.classList.remove('flex');
|
||||||
resetReply();
|
resetReply();
|
||||||
formContainer?.classList.add('hidden');
|
formContainer?.classList.add('hidden');
|
||||||
showMessage('评论已提交,审核通过后会显示在这里。', 'success');
|
showMessage(t('comments.submitSuccess'), 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showMessage(`提交失败:${error instanceof Error ? error.message : 'unknown error'}`, 'error');
|
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { terminalConfig } from '../lib/config/terminal';
|
|
||||||
import { DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
import { DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||||
|
import { getI18n } from '../lib/i18n';
|
||||||
import type { SiteSettings } from '../lib/types';
|
import type { SiteSettings } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,8 +8,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props;
|
const { siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props;
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
const social = siteSettings.social;
|
const social = siteSettings.social;
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
const tools = [
|
||||||
|
{ icon: 'fa-sitemap', href: '/sitemap.xml', title: t('footer.sitemap') },
|
||||||
|
{ icon: 'fa-rss', href: '/rss.xml', title: t('footer.rss') },
|
||||||
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class="border-t border-[var(--border-color)]/70 mt-auto py-8">
|
<footer class="border-t border-[var(--border-color)]/70 mt-auto py-8">
|
||||||
@@ -18,13 +23,13 @@ const currentYear = new Date().getFullYear();
|
|||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div class="terminal-toolbar-module min-w-[14rem]">
|
<div class="terminal-toolbar-module min-w-[14rem]">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="terminal-toolbar-label">session</div>
|
<div class="terminal-toolbar-label">{t('footer.session')}</div>
|
||||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">© {currentYear} {siteSettings.siteName}. All rights reserved.</p>
|
<p class="mt-1 text-sm text-[var(--text-secondary)]">{t('footer.copyright', { year: currentYear, site: siteSettings.siteName })}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
{terminalConfig.tools.map(tool => (
|
{tools.map(tool => (
|
||||||
<a
|
<a
|
||||||
href={tool.href}
|
href={tool.href}
|
||||||
class="terminal-toolbar-iconbtn"
|
class="terminal-toolbar-iconbtn"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { API_BASE_URL, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
import { API_BASE_URL, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||||
|
import { getI18n } from '../lib/i18n';
|
||||||
import type { SiteSettings } from '../lib/types';
|
import type { SiteSettings } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,6 +9,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props;
|
const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props;
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -21,21 +23,21 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="terminal-kicker">friend-link request</div>
|
<div class="terminal-kicker">friend-link request</div>
|
||||||
<h3 class="mt-3 text-xl font-bold text-[var(--title-color)]">提交友链申请</h3>
|
<h3 class="mt-3 text-xl font-bold text-[var(--title-color)]">{t('friendForm.title')}</h3>
|
||||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
填写站点信息后会提交到后台审核,审核通过后前台会自动展示。
|
{t('friendForm.intro')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="terminal-stat-pill self-start sm:self-auto">
|
<div class="terminal-stat-pill self-start sm:self-auto">
|
||||||
<i class="fas fa-shield-alt text-[var(--primary)]"></i>
|
<i class="fas fa-shield-alt text-[var(--primary)]"></i>
|
||||||
<span>后台审核后上线</span>
|
<span>{t('friendForm.reviewedOnline')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
||||||
<i class="fas fa-user mr-1"></i>站点名称 <span class="text-[var(--primary)]">*</span>
|
<i class="fas fa-user mr-1"></i>{t('friendForm.siteName')} <span class="text-[var(--primary)]">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -47,7 +49,7 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
||||||
<i class="fas fa-link mr-1"></i>站点链接 <span class="text-[var(--primary)]">*</span>
|
<i class="fas fa-link mr-1"></i>{t('friendForm.siteUrl')} <span class="text-[var(--primary)]">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
@@ -62,7 +64,7 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
||||||
<i class="fas fa-image mr-1"></i>头像链接 <span class="text-[var(--text-tertiary)] text-xs">(可选)</span>
|
<i class="fas fa-image mr-1"></i>{t('friendForm.avatarUrl')} <span class="text-[var(--text-tertiary)] text-xs">({t('common.optional')})</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
@@ -73,14 +75,19 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-[var(--text-secondary)] mb-2">
|
<label class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||||
<i class="fas fa-folder mr-1"></i>分类 <span class="text-[var(--text-tertiary)] text-xs">(可选)</span>
|
<i class="fas fa-folder mr-1"></i>{t('friendForm.category')} <span class="text-[var(--text-tertiary)] text-xs">({t('common.optional')})</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
{['tech', 'life', 'design', 'other'].map(category => (
|
{[
|
||||||
|
{ value: 'tech', label: t('friendForm.categoryTech') },
|
||||||
|
{ value: 'life', label: t('friendForm.categoryLife') },
|
||||||
|
{ value: 'design', label: t('friendForm.categoryDesign') },
|
||||||
|
{ value: 'other', label: t('friendForm.categoryOther') },
|
||||||
|
].map(category => (
|
||||||
<label class="ui-filter-pill ui-filter-pill--amber cursor-pointer">
|
<label class="ui-filter-pill ui-filter-pill--amber cursor-pointer">
|
||||||
<input type="radio" name="category" value={category} class="sr-only" />
|
<input type="radio" name="category" value={category.value} class="sr-only" />
|
||||||
<i class="fas fa-angle-right text-[10px] opacity-70"></i>
|
<i class="fas fa-angle-right text-[10px] opacity-70"></i>
|
||||||
<span class="text-sm">[{category}]</span>
|
<span class="text-sm">[{category.label}]</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,17 +96,17 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
||||||
<i class="fas fa-align-left mr-1"></i>站点描述 <span class="text-[var(--primary)]">*</span>
|
<i class="fas fa-align-left mr-1"></i>{t('friendForm.description')} <span class="text-[var(--primary)]">*</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
required
|
required
|
||||||
rows="3"
|
rows="3"
|
||||||
maxlength="200"
|
maxlength="200"
|
||||||
placeholder="describe your site..."
|
placeholder={t('friendForm.descriptionPlaceholder')}
|
||||||
class="terminal-form-textarea resize-none"
|
class="terminal-form-textarea resize-none"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 text-right">最多 200 字</p>
|
<p class="text-xs text-[var(--text-tertiary)] mt-1 text-right">{t('common.maxChars', { count: 200 })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="terminal-panel-muted flex items-start gap-3">
|
<div class="terminal-panel-muted flex items-start gap-3">
|
||||||
@@ -110,35 +117,35 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
class="mt-1 h-4 w-4 rounded border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
class="mt-1 h-4 w-4 rounded border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||||
/>
|
/>
|
||||||
<label for="has-reciprocal" class="text-sm leading-6 text-[var(--text-secondary)]">
|
<label for="has-reciprocal" class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
已添加本站友链 <span class="text-[var(--primary)]">*</span>
|
{t('friendForm.reciprocal')} <span class="text-[var(--primary)]">*</span>
|
||||||
<span class="block text-xs text-[var(--text-tertiary)]">这是提交申请前的必要条件。</span>
|
<span class="block text-xs text-[var(--text-tertiary)]">{t('friendForm.reciprocalHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="reciprocal-info" class="terminal-panel-muted hidden">
|
<div id="reciprocal-info" class="terminal-panel-muted hidden">
|
||||||
<p class="text-sm text-[var(--text-secondary)] mb-2">
|
<p class="text-sm text-[var(--text-secondary)] mb-2">
|
||||||
<i class="fas fa-info-circle mr-1"></i>本站信息:
|
<i class="fas fa-info-circle mr-1"></i>{t('friends.siteInfo')}
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-1 text-sm">
|
<div class="space-y-1 text-sm">
|
||||||
<p class="flex items-center gap-2">
|
<p class="flex items-center gap-2">
|
||||||
<span class="text-[var(--text-tertiary)]">名称:</span>
|
<span class="text-[var(--text-tertiary)]">{t('friends.name')}:</span>
|
||||||
<span class="text-[var(--text)] font-medium">{siteSettings.siteName}</span>
|
<span class="text-[var(--text)] font-medium">{siteSettings.siteName}</span>
|
||||||
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteName}>
|
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteName}>
|
||||||
<i class="fas fa-copy"></i>复制
|
<i class="fas fa-copy"></i>{t('friendForm.copy')}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="flex items-center gap-2">
|
<p class="flex items-center gap-2">
|
||||||
<span class="text-[var(--text-tertiary)]">链接:</span>
|
<span class="text-[var(--text-tertiary)]">{t('friends.link')}:</span>
|
||||||
<span class="text-[var(--text)]">{siteSettings.siteUrl}</span>
|
<span class="text-[var(--text)]">{siteSettings.siteUrl}</span>
|
||||||
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteUrl}>
|
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteUrl}>
|
||||||
<i class="fas fa-copy"></i>复制
|
<i class="fas fa-copy"></i>{t('friendForm.copy')}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="flex items-center gap-2">
|
<p class="flex items-center gap-2">
|
||||||
<span class="text-[var(--text-tertiary)]">描述:</span>
|
<span class="text-[var(--text-tertiary)]">{t('friends.description')}:</span>
|
||||||
<span class="text-[var(--text)]">{siteSettings.siteDescription}</span>
|
<span class="text-[var(--text)]">{siteSettings.siteDescription}</span>
|
||||||
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteDescription}>
|
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteDescription}>
|
||||||
<i class="fas fa-copy"></i>复制
|
<i class="fas fa-copy"></i>{t('friendForm.copy')}
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,13 +156,13 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
type="submit"
|
type="submit"
|
||||||
class="terminal-action-button terminal-action-button-primary"
|
class="terminal-action-button terminal-action-button-primary"
|
||||||
>
|
>
|
||||||
<i class="fas fa-paper-plane"></i>提交申请
|
<i class="fas fa-paper-plane"></i>{t('friendForm.submit')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="reset"
|
type="reset"
|
||||||
class="terminal-action-button"
|
class="terminal-action-button"
|
||||||
>
|
>
|
||||||
<i class="fas fa-undo"></i>重置
|
<i class="fas fa-undo"></i>{t('friendForm.reset')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,6 +171,7 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const t = window.__termiTranslate;
|
||||||
const wrapper = document.querySelector('.terminal-friend-link-form');
|
const wrapper = document.querySelector('.terminal-friend-link-form');
|
||||||
const form = document.getElementById('friend-link-form') as HTMLFormElement | null;
|
const form = document.getElementById('friend-link-form') as HTMLFormElement | null;
|
||||||
const reciprocalCheckbox = document.getElementById('has-reciprocal') as HTMLInputElement | null;
|
const reciprocalCheckbox = document.getElementById('has-reciprocal') as HTMLInputElement | null;
|
||||||
@@ -181,7 +189,7 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
const text = btn.getAttribute('data-text') || '';
|
const text = btn.getAttribute('data-text') || '';
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
const originalHTML = btn.innerHTML;
|
const originalHTML = btn.innerHTML;
|
||||||
btn.innerHTML = '<i class="fas fa-check"></i>已复制';
|
btn.innerHTML = `<i class="fas fa-check"></i>${t('friendForm.copied')}`;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.innerHTML = originalHTML;
|
btn.innerHTML = originalHTML;
|
||||||
}, 1800);
|
}, 1800);
|
||||||
@@ -211,12 +219,12 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
const hasReciprocal = formData.get('hasReciprocal') === 'on';
|
const hasReciprocal = formData.get('hasReciprocal') === 'on';
|
||||||
|
|
||||||
if (!hasReciprocal) {
|
if (!hasReciprocal) {
|
||||||
showMessage('请先添加本站友链后再提交申请。', 'error');
|
showMessage(t('friendForm.addReciprocalFirst'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
showMessage('正在提交友链申请...', 'info');
|
showMessage(t('friendForm.submitting'), 'info');
|
||||||
|
|
||||||
const response = await fetch(`${apiBase}/friend_links`, {
|
const response = await fetch(`${apiBase}/friend_links`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -238,9 +246,9 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
|||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
reciprocalInfo?.classList.add('hidden');
|
reciprocalInfo?.classList.add('hidden');
|
||||||
showMessage('友链申请已提交,我们会尽快审核。', 'success');
|
showMessage(t('friendForm.submitSuccess'), 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showMessage(`提交失败:${error instanceof Error ? error.message : 'unknown error'}`, 'error');
|
showMessage(t('friendForm.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
---
|
---
|
||||||
|
import { getI18n } from '../lib/i18n';
|
||||||
import type { FriendLink } from '../lib/types';
|
import type { FriendLink } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -6,6 +7,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { friend } = Astro.props;
|
const { friend } = Astro.props;
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
---
|
---
|
||||||
|
|
||||||
<a
|
<a
|
||||||
@@ -58,11 +60,11 @@ const { friend } = Astro.props;
|
|||||||
<span>{friend.category}</span>
|
<span>{friend.category}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">external link</span>
|
<span class="text-xs text-[var(--text-tertiary)] font-mono">{t('friendCard.externalLink')}</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span class="terminal-link-arrow">
|
<span class="terminal-link-arrow">
|
||||||
<span>访问</span>
|
<span>{t('common.visit')}</span>
|
||||||
<i class="fas fa-arrow-up-right-from-square text-xs"></i>
|
<i class="fas fa-arrow-up-right-from-square text-xs"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { terminalConfig } from '../lib/config/terminal';
|
import { terminalConfig } from '../lib/config/terminal';
|
||||||
|
import { getI18n, SUPPORTED_LOCALES } from '../lib/i18n';
|
||||||
import type { SiteSettings } from '../lib/types';
|
import type { SiteSettings } from '../lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,11 +12,28 @@ const {
|
|||||||
siteName = Astro.props.siteSettings?.siteShortName || terminalConfig.branding?.shortName || 'Termi'
|
siteName = Astro.props.siteSettings?.siteShortName || terminalConfig.branding?.shortName || 'Termi'
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
const navItems = terminalConfig.navLinks;
|
const { locale, t, buildLocaleUrl } = getI18n(Astro);
|
||||||
|
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
|
||||||
|
const navItems = [
|
||||||
|
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
||||||
|
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
|
||||||
|
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
|
||||||
|
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
|
||||||
|
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
|
||||||
|
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
|
||||||
|
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
|
||||||
|
...(aiEnabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
|
||||||
|
];
|
||||||
|
const localeLinks = SUPPORTED_LOCALES.map((item) => ({
|
||||||
|
locale: item,
|
||||||
|
href: buildLocaleUrl(item),
|
||||||
|
label: t(`common.languages.${item}`),
|
||||||
|
shortLabel: item === 'zh-CN' ? '中' : 'EN',
|
||||||
|
}));
|
||||||
const currentPath = Astro.url.pathname;
|
const currentPath = Astro.url.pathname;
|
||||||
---
|
---
|
||||||
|
|
||||||
<header class="sticky top-0 z-50 border-b border-[var(--border-color)] backdrop-blur-xl" style="background-color: color-mix(in oklab, var(--bg) 88%, transparent);">
|
<header data-ai-search-enabled={aiEnabled ? 'true' : 'false'} class="sticky top-0 z-50 border-b border-[var(--border-color)] backdrop-blur-xl" style="background-color: color-mix(in oklab, var(--bg) 88%, transparent);">
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||||
<div class="terminal-toolbar-shell">
|
<div class="terminal-toolbar-shell">
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -54,17 +72,39 @@ const currentPath = Astro.url.pathname;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative hidden md:block flex-1 min-w-0">
|
<div class="relative hidden md:block flex-1 min-w-0">
|
||||||
<div class="terminal-toolbar-module">
|
<div class="terminal-toolbar-module gap-3">
|
||||||
<div class="terminal-toolbar-label">grep -i</div>
|
<div class="terminal-toolbar-label" id="search-label">grep -i</div>
|
||||||
|
{aiEnabled && (
|
||||||
|
<div id="search-mode-panel" class="flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-mode-btn rounded-lg px-3 py-2 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||||
|
data-search-mode="keyword"
|
||||||
|
aria-pressed="true"
|
||||||
|
>
|
||||||
|
<i class="fas fa-search mr-1 text-[11px]"></i>
|
||||||
|
<span>{t('header.searchModeKeyword')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-mode-btn rounded-lg px-3 py-2 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||||
|
data-search-mode="ai"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot mr-1 text-[11px]"></i>
|
||||||
|
<span>{t('header.searchModeAi')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="search-input"
|
id="search-input"
|
||||||
placeholder="'关键词'"
|
placeholder={t('header.searchPlaceholderKeyword')}
|
||||||
class="terminal-console-input"
|
class="terminal-console-input"
|
||||||
/>
|
/>
|
||||||
<span class="hidden xl:inline text-xs font-mono text-[var(--secondary)]">articles/*.md</span>
|
<span id="search-hint" class="hidden xl:inline text-xs font-mono text-[var(--secondary)]">articles/*.md</span>
|
||||||
<button id="search-btn" class="terminal-toolbar-iconbtn">
|
<button id="search-btn" class="terminal-toolbar-iconbtn">
|
||||||
<i class="fas fa-search text-sm"></i>
|
<i id="search-btn-icon" class="fas fa-search text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -73,11 +113,30 @@ const currentPath = Astro.url.pathname;
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="hidden sm:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||||
|
{localeLinks.map((item) => (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
data-locale-switch={item.locale}
|
||||||
|
class:list={[
|
||||||
|
'rounded-lg px-3 py-2 text-xs font-semibold transition',
|
||||||
|
item.locale === locale
|
||||||
|
? 'bg-[var(--primary)] text-white shadow-sm'
|
||||||
|
: 'text-[var(--text-secondary)] hover:bg-[var(--header-bg)]'
|
||||||
|
]}
|
||||||
|
aria-current={item.locale === locale ? 'true' : undefined}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
{item.shortLabel}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="theme-toggle"
|
id="theme-toggle"
|
||||||
class="theme-toggle terminal-toolbar-iconbtn h-11 w-11 shrink-0"
|
class="theme-toggle terminal-toolbar-iconbtn h-11 w-11 shrink-0"
|
||||||
aria-label="切换主题"
|
aria-label={t('header.themeToggle')}
|
||||||
title="切换主题"
|
title={t('header.themeToggle')}
|
||||||
>
|
>
|
||||||
<i id="theme-icon" class="fas fa-moon text-[var(--primary)]"></i>
|
<i id="theme-icon" class="fas fa-moon text-[var(--primary)]"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -85,14 +144,14 @@ const currentPath = Astro.url.pathname;
|
|||||||
<button
|
<button
|
||||||
id="mobile-menu-btn"
|
id="mobile-menu-btn"
|
||||||
class="lg:hidden terminal-toolbar-iconbtn h-11 w-11 shrink-0"
|
class="lg:hidden terminal-toolbar-iconbtn h-11 w-11 shrink-0"
|
||||||
aria-label="Toggle menu"
|
aria-label={t('header.toggleMenu')}
|
||||||
>
|
>
|
||||||
<i class="fas fa-bars text-[var(--text)]"></i>
|
<i class="fas fa-bars text-[var(--text)]"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hidden lg:flex items-center gap-3 border-t border-[var(--border-color)]/70 pt-3">
|
<div class="hidden lg:flex items-center gap-3 border-t border-[var(--border-color)]/70 pt-3">
|
||||||
<div class="terminal-toolbar-label">navigation</div>
|
<div class="terminal-toolbar-label">{t('header.navigation')}</div>
|
||||||
<nav class="min-w-0 flex-1 flex items-center gap-1.5 overflow-x-auto pb-1">
|
<nav class="min-w-0 flex-1 flex items-center gap-1.5 overflow-x-auto pb-1">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href));
|
const isActive = currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href));
|
||||||
@@ -117,18 +176,63 @@ const currentPath = Astro.url.pathname;
|
|||||||
<!-- Mobile Menu -->
|
<!-- Mobile Menu -->
|
||||||
<div id="mobile-menu" class="hidden lg:hidden border-t border-[var(--border-color)] bg-[var(--bg)]">
|
<div id="mobile-menu" class="hidden lg:hidden border-t border-[var(--border-color)] bg-[var(--bg)]">
|
||||||
<div class="px-4 py-3 space-y-3">
|
<div class="px-4 py-3 space-y-3">
|
||||||
<div class="terminal-toolbar-module md:hidden">
|
<div class="space-y-3 md:hidden">
|
||||||
<span class="terminal-toolbar-label">grep -i</span>
|
{aiEnabled && (
|
||||||
|
<div class="flex items-center gap-2 rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-mode-btn flex-1 rounded-xl px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||||
|
data-search-mode="keyword"
|
||||||
|
aria-pressed="true"
|
||||||
|
>
|
||||||
|
<i class="fas fa-search mr-2 text-xs"></i>
|
||||||
|
<span>{t('header.searchModeKeywordMobile')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-mode-btn flex-1 rounded-xl px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||||
|
data-search-mode="ai"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot mr-2 text-xs"></i>
|
||||||
|
<span>{t('header.searchModeAiMobile')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="terminal-toolbar-label">{t('common.language')}</span>
|
||||||
|
<div class="flex flex-1 items-center gap-2">
|
||||||
|
{localeLinks.map((item) => (
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
data-locale-switch={item.locale}
|
||||||
|
class:list={[
|
||||||
|
'flex-1 rounded-xl border px-3 py-2 text-center text-sm font-medium transition',
|
||||||
|
item.locale === locale
|
||||||
|
? 'border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]'
|
||||||
|
: 'border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--text-secondary)]'
|
||||||
|
]}
|
||||||
|
aria-current={item.locale === locale ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-toolbar-module">
|
||||||
|
<span class="terminal-toolbar-label" id="mobile-search-label">grep -i</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="mobile-search-input"
|
id="mobile-search-input"
|
||||||
placeholder="'关键词'"
|
placeholder={t('header.searchPlaceholderKeyword')}
|
||||||
class="terminal-console-input"
|
class="terminal-console-input"
|
||||||
/>
|
/>
|
||||||
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn">
|
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn">
|
||||||
<i class="fas fa-search text-sm"></i>
|
<i id="mobile-search-btn-icon" class="fas fa-search text-sm"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="mobile-search-hint" class="px-1 text-xs font-mono text-[var(--text-tertiary)]">articles/*.md</p>
|
||||||
|
</div>
|
||||||
{navItems.map(item => (
|
{navItems.map(item => (
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -320,11 +424,39 @@ const currentPath = Astro.url.pathname;
|
|||||||
updateTitle();
|
updateTitle();
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality
|
||||||
|
const headerRoot = document.querySelector('header[data-ai-search-enabled]');
|
||||||
|
const aiSearchEnabled = headerRoot?.getAttribute('data-ai-search-enabled') === 'true';
|
||||||
const searchInput = document.getElementById('search-input');
|
const searchInput = document.getElementById('search-input');
|
||||||
const searchBtn = document.getElementById('search-btn');
|
const searchBtn = document.getElementById('search-btn');
|
||||||
|
const searchBtnIcon = document.getElementById('search-btn-icon');
|
||||||
const searchResults = document.getElementById('search-results');
|
const searchResults = document.getElementById('search-results');
|
||||||
|
const searchLabel = document.getElementById('search-label');
|
||||||
|
const searchHint = document.getElementById('search-hint');
|
||||||
|
const mobileSearchLabel = document.getElementById('mobile-search-label');
|
||||||
|
const mobileSearchHint = document.getElementById('mobile-search-hint');
|
||||||
|
const mobileSearchBtnIcon = document.getElementById('mobile-search-btn-icon');
|
||||||
|
const searchModePanel = document.getElementById('search-mode-panel');
|
||||||
|
const searchModeButtons = Array.from(document.querySelectorAll('.search-mode-btn'));
|
||||||
|
const localeSwitchLinks = Array.from(document.querySelectorAll('[data-locale-switch]'));
|
||||||
const searchApiBase = 'http://localhost:5150/api';
|
const searchApiBase = 'http://localhost:5150/api';
|
||||||
|
const searchInputs = [searchInput, mobileSearchInput].filter(Boolean);
|
||||||
|
const t = window.__termiTranslate;
|
||||||
|
const searchModeConfig = {
|
||||||
|
keyword: {
|
||||||
|
label: 'grep -i',
|
||||||
|
hint: t('header.searchHintKeyword'),
|
||||||
|
placeholder: t('header.searchPlaceholderKeyword'),
|
||||||
|
buttonIcon: 'fa-search'
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
label: 'ask ai',
|
||||||
|
hint: t('header.searchHintAi'),
|
||||||
|
placeholder: t('header.searchPlaceholderAi'),
|
||||||
|
buttonIcon: 'fa-robot'
|
||||||
|
}
|
||||||
|
};
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
|
let currentSearchMode = 'keyword';
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return value
|
return value
|
||||||
@@ -349,6 +481,102 @@ const currentPath = Astro.url.pathname;
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSearchInputs(sourceInput) {
|
||||||
|
const nextValue = sourceInput && 'value' in sourceInput ? sourceInput.value : '';
|
||||||
|
searchInputs.forEach((input) => {
|
||||||
|
if (input !== sourceInput) {
|
||||||
|
input.value = nextValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryFromInput(input) {
|
||||||
|
return input && 'value' in input ? input.value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalizedUrl(path) {
|
||||||
|
const nextUrl = new URL(path, window.location.origin);
|
||||||
|
nextUrl.searchParams.set('lang', document.documentElement.lang || 'zh-CN');
|
||||||
|
return `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchTarget(query) {
|
||||||
|
return buildLocalizedUrl(
|
||||||
|
currentSearchMode === 'ai'
|
||||||
|
? `/ask?q=${encodeURIComponent(query)}`
|
||||||
|
: `/articles?search=${encodeURIComponent(query)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSearchModeUI() {
|
||||||
|
const config = searchModeConfig[currentSearchMode] || searchModeConfig.keyword;
|
||||||
|
|
||||||
|
if (searchLabel) {
|
||||||
|
searchLabel.textContent = config.label;
|
||||||
|
}
|
||||||
|
if (mobileSearchLabel) {
|
||||||
|
mobileSearchLabel.textContent = config.label;
|
||||||
|
}
|
||||||
|
if (searchHint) {
|
||||||
|
searchHint.textContent = config.hint;
|
||||||
|
}
|
||||||
|
if (mobileSearchHint) {
|
||||||
|
mobileSearchHint.textContent = config.hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchInputs.forEach((input) => {
|
||||||
|
input.setAttribute('placeholder', config.placeholder);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (searchBtnIcon) {
|
||||||
|
searchBtnIcon.className = `fas ${config.buttonIcon} text-sm`;
|
||||||
|
}
|
||||||
|
if (mobileSearchBtnIcon) {
|
||||||
|
mobileSearchBtnIcon.className = `fas ${config.buttonIcon} text-sm`;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchModeButtons.forEach((button) => {
|
||||||
|
const isActive = button.getAttribute('data-search-mode') === currentSearchMode;
|
||||||
|
button.setAttribute('aria-pressed', String(isActive));
|
||||||
|
button.classList.toggle('bg-[var(--primary)]', isActive);
|
||||||
|
button.classList.toggle('text-white', isActive);
|
||||||
|
button.classList.toggle('shadow-sm', isActive);
|
||||||
|
button.classList.toggle('text-[var(--text-secondary)]', !isActive);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAiSearchResults(query) {
|
||||||
|
if (!searchResults) return;
|
||||||
|
|
||||||
|
searchResults.innerHTML = `
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div class="border-b border-[var(--border-color)] px-4 py-2 text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||||
|
${escapeHtml(t('header.aiModeTitle'))}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4 px-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-sm font-semibold text-[var(--title-color)]">${escapeHtml(t('header.aiModeHeading'))}</div>
|
||||||
|
<p class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
|
${escapeHtml(t('header.aiModeDescription'))}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs leading-5 text-[var(--text-tertiary)]">
|
||||||
|
${escapeHtml(t('header.aiModeNotice'))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-3">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">${escapeHtml(t('common.search'))}</div>
|
||||||
|
<div class="mt-2 font-mono text-sm text-[var(--title-color)]">${escapeHtml(query)}</div>
|
||||||
|
</div>
|
||||||
|
<a href="${buildSearchTarget(query)}" class="flex items-center justify-between rounded-2xl border border-[var(--primary)]/30 bg-[var(--primary)]/10 px-4 py-3 text-sm font-medium text-[var(--primary)] transition hover:bg-[var(--primary)]/16">
|
||||||
|
<span><i class="fas fa-robot mr-2 text-xs"></i>${escapeHtml(t('header.aiModeCta'))}</span>
|
||||||
|
<i class="fas fa-arrow-right text-xs"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
searchResults.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
function hideSearchResults() {
|
function hideSearchResults() {
|
||||||
if (!searchResults) return;
|
if (!searchResults) return;
|
||||||
searchResults.classList.add('hidden');
|
searchResults.classList.add('hidden');
|
||||||
@@ -361,7 +589,7 @@ const currentPath = Astro.url.pathname;
|
|||||||
if (state === 'loading') {
|
if (state === 'loading') {
|
||||||
searchResults.innerHTML = `
|
searchResults.innerHTML = `
|
||||||
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
|
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
|
||||||
正在搜索 <span class="text-[var(--primary)] font-mono">${escapeHtml(query)}</span> ...
|
${escapeHtml(t('header.searching', { query }))}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
searchResults.classList.remove('hidden');
|
searchResults.classList.remove('hidden');
|
||||||
@@ -371,7 +599,7 @@ const currentPath = Astro.url.pathname;
|
|||||||
if (state === 'error') {
|
if (state === 'error') {
|
||||||
searchResults.innerHTML = `
|
searchResults.innerHTML = `
|
||||||
<div class="px-4 py-4 text-sm text-[var(--danger)]">
|
<div class="px-4 py-4 text-sm text-[var(--danger)]">
|
||||||
搜索失败,请稍后再试。
|
${escapeHtml(t('header.searchFailed'))}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
searchResults.classList.remove('hidden');
|
searchResults.classList.remove('hidden');
|
||||||
@@ -379,9 +607,18 @@ const currentPath = Astro.url.pathname;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!results.length) {
|
if (!results.length) {
|
||||||
|
const aiRetry = aiSearchEnabled
|
||||||
|
? `
|
||||||
|
<a href="${buildLocalizedUrl(`/ask?q=${encodeURIComponent(query)}`)}" class="mt-3 inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/30 bg-[var(--primary)]/10 px-3 py-1.5 text-xs font-medium text-[var(--primary)] transition hover:bg-[var(--primary)]/16">
|
||||||
|
<i class="fas fa-robot text-[11px]"></i>
|
||||||
|
<span>${escapeHtml(t('header.searchEmptyCta'))}</span>
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
searchResults.innerHTML = `
|
searchResults.innerHTML = `
|
||||||
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
|
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
|
||||||
没有找到和 <span class="text-[var(--primary)] font-mono">${escapeHtml(query)}</span> 相关的内容。
|
${escapeHtml(t('header.searchEmpty', { query }))}
|
||||||
|
${aiRetry}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
searchResults.classList.remove('hidden');
|
searchResults.classList.remove('hidden');
|
||||||
@@ -399,7 +636,7 @@ const currentPath = Astro.url.pathname;
|
|||||||
return `
|
return `
|
||||||
<a href="/articles/${encodeURIComponent(item.slug)}" class="block border-b border-[var(--border-color)] px-4 py-3 transition-colors hover:bg-[var(--header-bg)] last:border-b-0">
|
<a href="/articles/${encodeURIComponent(item.slug)}" class="block border-b border-[var(--border-color)] px-4 py-3 transition-colors hover:bg-[var(--header-bg)] last:border-b-0">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="text-sm font-semibold text-[var(--title-color)]">${highlightText(item.title || 'Untitled', query)}</div>
|
<div class="text-sm font-semibold text-[var(--title-color)]">${highlightText(item.title || t('header.untitled'), query)}</div>
|
||||||
<div class="text-[11px] text-[var(--text-tertiary)]">${escapeHtml(item.category || '')}</div>
|
<div class="text-[11px] text-[var(--text-tertiary)]">${escapeHtml(item.category || '')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs leading-5 text-[var(--text-secondary)]">${highlightText(item.description || item.content || '', query)}</div>
|
<div class="mt-1 text-xs leading-5 text-[var(--text-secondary)]">${highlightText(item.description || item.content || '', query)}</div>
|
||||||
@@ -408,15 +645,25 @@ const currentPath = Astro.url.pathname;
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
const aiFooter = aiSearchEnabled
|
||||||
|
? `
|
||||||
|
<a href="${buildLocalizedUrl(`/ask?q=${encodeURIComponent(query)}`)}" class="block border-t border-[var(--border-color)] px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
|
||||||
|
<i class="fas fa-robot mr-2 text-xs"></i>
|
||||||
|
${escapeHtml(t('header.searchAiFooter'))}
|
||||||
|
</a>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
searchResults.innerHTML = `
|
searchResults.innerHTML = `
|
||||||
<div class="max-h-[26rem] overflow-auto">
|
<div class="max-h-[26rem] overflow-auto">
|
||||||
<div class="border-b border-[var(--border-color)] px-4 py-2 text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
<div class="border-b border-[var(--border-color)] px-4 py-2 text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||||
实时搜索结果
|
${escapeHtml(t('header.liveResults'))}
|
||||||
</div>
|
</div>
|
||||||
${itemsHtml}
|
${itemsHtml}
|
||||||
<a href="/articles?search=${encodeURIComponent(query)}" class="block px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
|
<a href="${buildLocalizedUrl(`/articles?search=${encodeURIComponent(query)}`)}" class="block px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
|
||||||
查看全部结果
|
${escapeHtml(t('header.searchAllResults'))}
|
||||||
</a>
|
</a>
|
||||||
|
${aiFooter}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
searchResults.classList.remove('hidden');
|
searchResults.classList.remove('hidden');
|
||||||
@@ -428,6 +675,11 @@ const currentPath = Astro.url.pathname;
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentSearchMode === 'ai') {
|
||||||
|
renderAiSearchResults(query);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
renderSearchResults(query, [], 'loading');
|
renderSearchResults(query, [], 'loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -444,38 +696,59 @@ const currentPath = Astro.url.pathname;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitSearch() {
|
function setSearchMode(mode) {
|
||||||
const query = searchInput && 'value' in searchInput ? searchInput.value.trim() : '';
|
if (!aiSearchEnabled && mode === 'ai') {
|
||||||
if (query) {
|
currentSearchMode = 'keyword';
|
||||||
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
|
} else {
|
||||||
|
currentSearchMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSearchModeUI();
|
||||||
|
|
||||||
|
const query = getQueryFromInput(searchInput);
|
||||||
|
if (query && document.activeElement === searchInput) {
|
||||||
|
void runLiveSearch(query);
|
||||||
|
} else if (!query) {
|
||||||
|
hideSearchResults();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitSearch(preferredInput) {
|
||||||
|
const query = getQueryFromInput(preferredInput) || getQueryFromInput(searchInput) || getQueryFromInput(mobileSearchInput);
|
||||||
|
if (query) {
|
||||||
|
window.location.href = buildSearchTarget(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchModeButtons.forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const nextMode = button.getAttribute('data-search-mode') || 'keyword';
|
||||||
|
setSearchMode(nextMode);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
searchBtn?.addEventListener('click', function() {
|
searchBtn?.addEventListener('click', function() {
|
||||||
submitSearch();
|
submitSearch(searchInput);
|
||||||
});
|
});
|
||||||
mobileSearchBtn?.addEventListener('click', function() {
|
mobileSearchBtn?.addEventListener('click', function() {
|
||||||
const query = mobileSearchInput && 'value' in mobileSearchInput ? mobileSearchInput.value.trim() : '';
|
submitSearch(mobileSearchInput);
|
||||||
if (query) {
|
|
||||||
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
searchInput?.addEventListener('keydown', function(e) {
|
searchInput?.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
submitSearch();
|
e.preventDefault();
|
||||||
|
submitSearch(searchInput);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
mobileSearchInput?.addEventListener('keydown', function(e) {
|
mobileSearchInput?.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
const query = this.value.trim();
|
e.preventDefault();
|
||||||
if (query) {
|
submitSearch(mobileSearchInput);
|
||||||
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
searchInput?.addEventListener('input', function() {
|
searchInput?.addEventListener('input', function() {
|
||||||
|
syncSearchInputs(searchInput);
|
||||||
const query = this.value.trim();
|
const query = this.value.trim();
|
||||||
if (searchTimer) {
|
if (searchTimer) {
|
||||||
clearTimeout(searchTimer);
|
clearTimeout(searchTimer);
|
||||||
@@ -485,6 +758,10 @@ const currentPath = Astro.url.pathname;
|
|||||||
}, 180);
|
}, 180);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mobileSearchInput?.addEventListener('input', function() {
|
||||||
|
syncSearchInputs(mobileSearchInput);
|
||||||
|
});
|
||||||
|
|
||||||
searchInput?.addEventListener('focus', function() {
|
searchInput?.addEventListener('focus', function() {
|
||||||
const query = this.value.trim();
|
const query = this.value.trim();
|
||||||
if (query) {
|
if (query) {
|
||||||
@@ -492,11 +769,27 @@ const currentPath = Astro.url.pathname;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
syncSearchModeUI();
|
||||||
|
|
||||||
|
localeSwitchLinks.forEach((link) => {
|
||||||
|
link.addEventListener('click', () => {
|
||||||
|
const nextLocale = link.getAttribute('data-locale-switch');
|
||||||
|
if (!nextLocale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('locale', nextLocale);
|
||||||
|
document.cookie = `${'termi_locale'}=${encodeURIComponent(nextLocale)};path=/;max-age=31536000;samesite=lax`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (
|
if (
|
||||||
searchResults &&
|
searchResults &&
|
||||||
!searchResults.contains(target) &&
|
!searchResults.contains(target) &&
|
||||||
|
!searchModePanel?.contains(target) &&
|
||||||
|
!target?.closest?.('.search-mode-btn') &&
|
||||||
target !== searchInput &&
|
target !== searchInput &&
|
||||||
target !== searchBtn &&
|
target !== searchBtn &&
|
||||||
!searchBtn?.contains(target)
|
!searchBtn?.contains(target)
|
||||||
|
|||||||
748
frontend/src/components/ParagraphComments.astro
Normal file
748
frontend/src/components/ParagraphComments.astro
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
---
|
||||||
|
import { API_BASE_URL } from '../lib/api/client';
|
||||||
|
import { getI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
postSlug: string;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { postSlug, class: className = '' } = Astro.props;
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class={`paragraph-comments-shell ${className}`} data-post-slug={postSlug} data-api-base={API_BASE_URL}>
|
||||||
|
<div class="terminal-panel-muted paragraph-comments-intro">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<span class="terminal-kicker">
|
||||||
|
<i class="fas fa-paragraph"></i>
|
||||||
|
paragraph annotations
|
||||||
|
</span>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--title-color)]">{t('paragraphComments.title')}</h3>
|
||||||
|
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
|
{t('paragraphComments.intro')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="paragraph-comments-summary terminal-chip">
|
||||||
|
<i class="fas fa-terminal text-[var(--primary)]"></i>
|
||||||
|
<span data-summary-text>{t('paragraphComments.scanning')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const t = window.__termiTranslate;
|
||||||
|
const locale = document.documentElement.lang || 'zh-CN';
|
||||||
|
import { buildParagraphDescriptors } from '../lib/utils/paragraph-comments';
|
||||||
|
|
||||||
|
interface BrowserComment {
|
||||||
|
id: number;
|
||||||
|
author: string | null;
|
||||||
|
content: string | null;
|
||||||
|
created_at: string;
|
||||||
|
reply_to_comment_id: number | null;
|
||||||
|
paragraph_excerpt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingComment {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
reply_to_comment_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryItem {
|
||||||
|
paragraph_key: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrappers = document.querySelectorAll('.paragraph-comments-shell');
|
||||||
|
const wrapper = wrappers.item(wrappers.length - 1) as HTMLElement | null;
|
||||||
|
const postSlug = wrapper?.dataset.postSlug || '';
|
||||||
|
const apiBase = wrapper?.dataset.apiBase || 'http://localhost:5150/api';
|
||||||
|
const articleRoot = wrapper?.closest('[data-article-slug]') || document;
|
||||||
|
const articleContent = articleRoot.querySelector('.article-content') as HTMLElement | null;
|
||||||
|
const summaryText = wrapper?.querySelector('[data-summary-text]') as HTMLElement | null;
|
||||||
|
|
||||||
|
const paragraphCounts = new Map<string, number>();
|
||||||
|
const paragraphRows = new Map<string, HTMLElement>();
|
||||||
|
const paragraphDescriptors = new Map<
|
||||||
|
string,
|
||||||
|
ReturnType<typeof buildParagraphDescriptors>[number]
|
||||||
|
>();
|
||||||
|
const threadCache = new Map<string, BrowserComment[]>();
|
||||||
|
const pendingComments = new Map<string, PendingComment[]>();
|
||||||
|
|
||||||
|
let activeParagraphKey: string | null = null;
|
||||||
|
let activeReplyToCommentId: number | null = null;
|
||||||
|
let pendingCounter = 0;
|
||||||
|
|
||||||
|
function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommentDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toLocaleString(locale, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSummaryMessage(message: string) {
|
||||||
|
if (summaryText) {
|
||||||
|
summaryText.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLabel(count: number): string {
|
||||||
|
if (count <= 0) {
|
||||||
|
return t('paragraphComments.zeroNotes');
|
||||||
|
}
|
||||||
|
if (count === 1) {
|
||||||
|
return t('paragraphComments.oneNote');
|
||||||
|
}
|
||||||
|
return t('paragraphComments.manyNotes', { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewReplyText(value: string | null | undefined, limit = 88) {
|
||||||
|
const normalized = (value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (normalized.length <= limit) {
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${normalized.slice(0, limit).trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptLabel(key: string, active: boolean) {
|
||||||
|
return active ? `./comment --paragraph ${key} --open` : `./comment --paragraph ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchorForParagraph(key: string) {
|
||||||
|
return `#paragraph-${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphKeyFromHash(hash: string) {
|
||||||
|
const normalized = hash.startsWith('#') ? hash.slice(1) : hash;
|
||||||
|
if (!normalized.startsWith('paragraph-')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = normalized.slice('paragraph-'.length).trim();
|
||||||
|
return key || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRowState() {
|
||||||
|
paragraphRows.forEach((row, rowKey) => {
|
||||||
|
const trigger = row.querySelector('[data-trigger-label]') as HTMLElement | null;
|
||||||
|
const prompt = row.querySelector('[data-command-text]') as HTMLElement | null;
|
||||||
|
const count = paragraphCounts.get(rowKey) || 0;
|
||||||
|
const isActive = rowKey === activeParagraphKey;
|
||||||
|
|
||||||
|
row.classList.toggle('is-active', isActive);
|
||||||
|
if (trigger) {
|
||||||
|
trigger.textContent = countLabel(count);
|
||||||
|
}
|
||||||
|
if (prompt) {
|
||||||
|
prompt.textContent = promptLabel(rowKey, isActive);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummaryFromCounts() {
|
||||||
|
const paragraphCount = paragraphDescriptors.size;
|
||||||
|
const discussedParagraphs = Array.from(paragraphCounts.values()).filter(count => count > 0).length;
|
||||||
|
const approvedCount = Array.from(paragraphCounts.values()).reduce((sum, count) => sum + count, 0);
|
||||||
|
|
||||||
|
if (paragraphCount === 0) {
|
||||||
|
setSummaryMessage(t('paragraphComments.noParagraphs'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSummaryMessage(
|
||||||
|
t('paragraphComments.summary', {
|
||||||
|
paragraphCount,
|
||||||
|
discussedCount: discussedParagraphs,
|
||||||
|
approvedCount,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParagraphRow(key: string, excerpt: string) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'paragraph-comment-row';
|
||||||
|
row.dataset.paragraphKey = key;
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="paragraph-comment-command">
|
||||||
|
<span class="paragraph-comment-prompt">user@blog:~/articles$</span>
|
||||||
|
<span class="paragraph-comment-command-text" data-command-text>${escapeHtml(promptLabel(key, false))}</span>
|
||||||
|
</div>
|
||||||
|
<div class="paragraph-comment-actions">
|
||||||
|
<span class="paragraph-comment-hint" title="${escapeHtml(excerpt)}">${escapeHtml(t('paragraphComments.focusCurrent'))}</span>
|
||||||
|
<button type="button" class="terminal-action-button paragraph-comment-trigger">
|
||||||
|
<i class="fas fa-message"></i>
|
||||||
|
<span data-trigger-label>${countLabel(0)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const button = row.querySelector('.paragraph-comment-trigger') as HTMLButtonElement | null;
|
||||||
|
button?.addEventListener('click', () => {
|
||||||
|
void openPanelForParagraph(key, { focusForm: true, syncHash: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = document.createElement('section');
|
||||||
|
panel.className = 'paragraph-comment-panel terminal-panel hidden';
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="paragraph-comment-panel-head">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<span class="terminal-kicker">
|
||||||
|
<i class="fas fa-terminal"></i>
|
||||||
|
paragraph thread
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold text-[var(--title-color)]">${escapeHtml(t('paragraphComments.panelTitle'))}</h4>
|
||||||
|
<p class="paragraph-comment-panel-excerpt text-sm leading-7 text-[var(--text-secondary)]"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="paragraph-comment-panel-meta">
|
||||||
|
<span class="terminal-chip" data-panel-count>${escapeHtml(t('paragraphComments.zeroNotes'))}</span>
|
||||||
|
<span class="terminal-chip hidden" data-pending-count>0 ${escapeHtml(t('common.pending'))}</span>
|
||||||
|
<button type="button" class="terminal-action-button" data-close-panel>
|
||||||
|
<i class="fas fa-xmark"></i>
|
||||||
|
<span>${escapeHtml(t('paragraphComments.close'))}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paragraph-comment-thread" data-thread></div>
|
||||||
|
|
||||||
|
<form class="paragraph-comment-form">
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="terminal-form-label">
|
||||||
|
${escapeHtml(t('paragraphComments.nickname'))} <span class="text-[var(--primary)]">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="nickname"
|
||||||
|
required
|
||||||
|
placeholder="inline_operator"
|
||||||
|
class="terminal-form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="terminal-form-label">
|
||||||
|
${escapeHtml(t('paragraphComments.email'))} <span class="text-[var(--text-tertiary)] normal-case tracking-normal">(${escapeHtml(t('common.optional'))})</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
class="terminal-form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paragraph-comment-reply terminal-panel-muted hidden" data-reply-banner>
|
||||||
|
<span class="text-sm text-[var(--text-secondary)]">
|
||||||
|
${escapeHtml(t('paragraphComments.replyTo'))} -> <span class="font-medium text-[var(--primary)]" data-reply-target></span>
|
||||||
|
</span>
|
||||||
|
<button type="button" class="terminal-action-button" data-cancel-reply>
|
||||||
|
<i class="fas fa-rotate-left"></i>
|
||||||
|
<span>${escapeHtml(t('common.clear'))}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="terminal-form-label">
|
||||||
|
${escapeHtml(t('paragraphComments.comment'))} <span class="text-[var(--primary)]">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
name="content"
|
||||||
|
required
|
||||||
|
rows="5"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="${escapeHtml(t('paragraphComments.commentPlaceholder'))}"
|
||||||
|
class="terminal-form-textarea resize-y"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">${escapeHtml(t('paragraphComments.maxChars'))}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<button type="submit" class="terminal-action-button terminal-action-button-primary">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
<span>${escapeHtml(t('common.submit'))}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="terminal-action-button" data-focus-paragraph>
|
||||||
|
<i class="fas fa-crosshairs"></i>
|
||||||
|
<span>${escapeHtml(t('paragraphComments.locateParagraph'))}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="paragraph-comment-status hidden" data-status></div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const panelExcerpt = panel.querySelector('.paragraph-comment-panel-excerpt') as HTMLElement;
|
||||||
|
const panelCount = panel.querySelector('[data-panel-count]') as HTMLElement;
|
||||||
|
const pendingCountChip = panel.querySelector('[data-pending-count]') as HTMLElement;
|
||||||
|
const threadContainer = panel.querySelector('[data-thread]') as HTMLElement;
|
||||||
|
const statusBox = panel.querySelector('[data-status]') as HTMLElement;
|
||||||
|
const form = panel.querySelector('.paragraph-comment-form') as HTMLFormElement;
|
||||||
|
const replyBanner = panel.querySelector('[data-reply-banner]') as HTMLElement;
|
||||||
|
const replyTarget = panel.querySelector('[data-reply-target]') as HTMLElement;
|
||||||
|
const focusButton = panel.querySelector('[data-focus-paragraph]') as HTMLButtonElement;
|
||||||
|
|
||||||
|
function clearStatus() {
|
||||||
|
statusBox.className = 'paragraph-comment-status hidden';
|
||||||
|
statusBox.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message: string, tone: 'success' | 'error' | 'info') {
|
||||||
|
statusBox.className = `paragraph-comment-status paragraph-comment-status-${tone}`;
|
||||||
|
statusBox.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetReplyState() {
|
||||||
|
activeReplyToCommentId = null;
|
||||||
|
replyBanner.classList.add('hidden');
|
||||||
|
replyTarget.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReplyState(commentId: number, author: string) {
|
||||||
|
activeReplyToCommentId = commentId;
|
||||||
|
replyBanner.classList.remove('hidden');
|
||||||
|
replyTarget.textContent = author;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentCardMarkup(
|
||||||
|
comment: {
|
||||||
|
id: number | string;
|
||||||
|
author: string | null;
|
||||||
|
content: string | null;
|
||||||
|
created_at: string;
|
||||||
|
reply_to_comment_id: number | null;
|
||||||
|
},
|
||||||
|
options?: {
|
||||||
|
pending?: boolean;
|
||||||
|
replyAuthor?: string | null;
|
||||||
|
replyPreview?: string | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const author = comment.author || t('paragraphComments.anonymous');
|
||||||
|
const pending = options?.pending || false;
|
||||||
|
const replyAuthor = options?.replyAuthor;
|
||||||
|
const replyPreview = options?.replyPreview;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="paragraph-thread-item ${replyAuthor ? 'is-reply' : ''} ${pending ? 'is-pending' : ''}">
|
||||||
|
<div class="paragraph-thread-head">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-semibold text-[var(--title-color)]">${escapeHtml(author)}</span>
|
||||||
|
<span class="terminal-chip px-2 py-1 text-[10px]">
|
||||||
|
<i class="far fa-clock text-[var(--primary)]"></i>
|
||||||
|
${escapeHtml(formatCommentDate(comment.created_at))}
|
||||||
|
</span>
|
||||||
|
${
|
||||||
|
pending
|
||||||
|
? `<span class="terminal-chip px-2 py-1 text-[10px]">
|
||||||
|
<i class="fas fa-hourglass-half text-[var(--warning)]"></i>
|
||||||
|
${escapeHtml(t('paragraphComments.waitingReview'))}
|
||||||
|
</span>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${
|
||||||
|
replyAuthor
|
||||||
|
? `<span class="terminal-chip px-2 py-1 text-[10px]">
|
||||||
|
<i class="fas fa-reply text-[var(--primary)]"></i>
|
||||||
|
${escapeHtml(replyAuthor)}
|
||||||
|
</span>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
${
|
||||||
|
replyPreview
|
||||||
|
? `<p class="paragraph-thread-quote">${escapeHtml(replyPreview)}</p>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
${
|
||||||
|
pending
|
||||||
|
? ''
|
||||||
|
: `<button
|
||||||
|
type="button"
|
||||||
|
class="terminal-action-button px-3 py-2 text-[11px]"
|
||||||
|
data-reply-id="${comment.id}"
|
||||||
|
data-reply-author="${escapeHtml(author)}"
|
||||||
|
>
|
||||||
|
<i class="fas fa-reply"></i>
|
||||||
|
<span>${escapeHtml(t('common.reply'))}</span>
|
||||||
|
</button>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm leading-7 text-[var(--text-secondary)]">${escapeHtml(comment.content || '')}</p>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThread(paragraphKey: string, comments: BrowserComment[]) {
|
||||||
|
const pending = pendingComments.get(paragraphKey) || [];
|
||||||
|
const authorById = new Map(comments.map(comment => [comment.id, comment.author || t('paragraphComments.anonymous')]));
|
||||||
|
const contentById = new Map(comments.map(comment => [comment.id, comment.content || '']));
|
||||||
|
|
||||||
|
panelCount.textContent = countLabel(comments.length);
|
||||||
|
paragraphCounts.set(paragraphKey, comments.length);
|
||||||
|
pendingCountChip.textContent = `${pending.length} ${t('common.pending')}`;
|
||||||
|
pendingCountChip.classList.toggle('hidden', pending.length === 0);
|
||||||
|
updateRowState();
|
||||||
|
updateSummaryFromCounts();
|
||||||
|
|
||||||
|
const approvedMarkup =
|
||||||
|
comments.length === 0
|
||||||
|
? `
|
||||||
|
<div class="terminal-empty">
|
||||||
|
<div class="mx-auto flex max-w-md flex-col items-center gap-3">
|
||||||
|
<span class="terminal-section-icon">
|
||||||
|
<i class="fas fa-comment-slash"></i>
|
||||||
|
</span>
|
||||||
|
<h5 class="text-base font-semibold text-[var(--title-color)]">${escapeHtml(t('paragraphComments.emptyTitle'))}</h5>
|
||||||
|
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
|
${escapeHtml(t('paragraphComments.emptyDescription'))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: comments
|
||||||
|
.map(comment =>
|
||||||
|
commentCardMarkup(comment, {
|
||||||
|
replyAuthor: comment.reply_to_comment_id
|
||||||
|
? authorById.get(comment.reply_to_comment_id) || `#${comment.reply_to_comment_id}`
|
||||||
|
: null,
|
||||||
|
replyPreview: comment.reply_to_comment_id
|
||||||
|
? previewReplyText(contentById.get(comment.reply_to_comment_id) || null)
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const pendingMarkup =
|
||||||
|
pending.length === 0
|
||||||
|
? ''
|
||||||
|
: `
|
||||||
|
<section class="paragraph-thread-segment">
|
||||||
|
<div class="paragraph-thread-segment-label">${escapeHtml(t('paragraphComments.pendingQueue'))}</div>
|
||||||
|
${pending
|
||||||
|
.map(comment =>
|
||||||
|
commentCardMarkup(comment, {
|
||||||
|
pending: true,
|
||||||
|
replyAuthor: comment.reply_to_comment_id
|
||||||
|
? authorById.get(comment.reply_to_comment_id) || `#${comment.reply_to_comment_id}`
|
||||||
|
: null,
|
||||||
|
replyPreview: comment.reply_to_comment_id
|
||||||
|
? previewReplyText(contentById.get(comment.reply_to_comment_id) || null)
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
|
||||||
|
threadContainer.innerHTML = `
|
||||||
|
<section class="paragraph-thread-segment">
|
||||||
|
<div class="paragraph-thread-segment-label">${escapeHtml(t('paragraphComments.approvedThread'))}</div>
|
||||||
|
${approvedMarkup}
|
||||||
|
</section>
|
||||||
|
${pendingMarkup}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadThread(paragraphKey: string, forceReload = false) {
|
||||||
|
if (!forceReload && threadCache.has(paragraphKey)) {
|
||||||
|
return threadCache.get(paragraphKey) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`${apiBase}/comments?${new URLSearchParams({
|
||||||
|
post_slug: postSlug,
|
||||||
|
scope: 'paragraph',
|
||||||
|
paragraph_key: paragraphKey,
|
||||||
|
approved: 'true',
|
||||||
|
}).toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = (await response.json()) as BrowserComment[];
|
||||||
|
threadCache.set(paragraphKey, comments);
|
||||||
|
return comments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncHashForParagraph(key: string | null) {
|
||||||
|
const nextUrl = new URL(window.location.href);
|
||||||
|
nextUrl.hash = key ? anchorForParagraph(key) : '';
|
||||||
|
history.replaceState(null, '', nextUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPanelForParagraph(
|
||||||
|
paragraphKey: string,
|
||||||
|
options?: {
|
||||||
|
focusForm?: boolean;
|
||||||
|
forceReload?: boolean;
|
||||||
|
syncHash?: boolean;
|
||||||
|
scrollIntoView?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const descriptor = paragraphDescriptors.get(paragraphKey);
|
||||||
|
const row = paragraphRows.get(paragraphKey);
|
||||||
|
|
||||||
|
if (!descriptor || !row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeParagraphKey = paragraphKey;
|
||||||
|
clearStatus();
|
||||||
|
resetReplyState();
|
||||||
|
panelExcerpt.textContent = descriptor.excerpt;
|
||||||
|
row.insertAdjacentElement('afterend', panel);
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
panel.dataset.paragraphKey = paragraphKey;
|
||||||
|
|
||||||
|
paragraphDescriptors.forEach((item, key) => {
|
||||||
|
item.element.classList.toggle('is-comment-focused', key === paragraphKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options?.syncHash !== false) {
|
||||||
|
syncHashForParagraph(paragraphKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.scrollIntoView) {
|
||||||
|
descriptor.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRowState();
|
||||||
|
threadContainer.innerHTML = `
|
||||||
|
<div class="terminal-panel-muted text-sm text-[var(--text-secondary)]">
|
||||||
|
${escapeHtml(t('paragraphComments.loadingThread'))}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comments = await loadThread(paragraphKey, options?.forceReload || false);
|
||||||
|
renderThread(paragraphKey, comments);
|
||||||
|
if (options?.focusForm) {
|
||||||
|
form.querySelector('textarea')?.focus();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
panelCount.textContent = t('paragraphComments.loadFailedShort');
|
||||||
|
pendingCountChip.classList.add('hidden');
|
||||||
|
threadContainer.innerHTML = `
|
||||||
|
<div class="paragraph-comment-status paragraph-comment-status-error">
|
||||||
|
${escapeHtml(t('paragraphComments.loadFailed', { message: error instanceof Error ? error.message : 'unknown error' }))}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel(clearHash = true) {
|
||||||
|
activeParagraphKey = null;
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
resetReplyState();
|
||||||
|
clearStatus();
|
||||||
|
paragraphDescriptors.forEach(item => item.element.classList.remove('is-comment-focused'));
|
||||||
|
updateRowState();
|
||||||
|
|
||||||
|
if (clearHash) {
|
||||||
|
syncHashForParagraph(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFromHash() {
|
||||||
|
const paragraphKey = paragraphKeyFromHash(window.location.hash);
|
||||||
|
if (!paragraphKey) {
|
||||||
|
if (activeParagraphKey) {
|
||||||
|
closePanel(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!paragraphDescriptors.has(paragraphKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await openPanelForParagraph(paragraphKey, {
|
||||||
|
focusForm: false,
|
||||||
|
forceReload: activeParagraphKey === paragraphKey,
|
||||||
|
syncHash: false,
|
||||||
|
scrollIntoView: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.addEventListener('click', event => {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const replyButton = target?.closest('[data-reply-id]') as HTMLButtonElement | null;
|
||||||
|
const closeButton = target?.closest('[data-close-panel]') as HTMLButtonElement | null;
|
||||||
|
const cancelReplyButton = target?.closest('[data-cancel-reply]') as HTMLButtonElement | null;
|
||||||
|
|
||||||
|
if (replyButton) {
|
||||||
|
const replyId = Number(replyButton.dataset.replyId || '0');
|
||||||
|
const author = replyButton.dataset.replyAuthor || t('paragraphComments.anonymous');
|
||||||
|
if (replyId > 0) {
|
||||||
|
setReplyState(replyId, author);
|
||||||
|
form.querySelector('textarea')?.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (closeButton) {
|
||||||
|
closePanel(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelReplyButton) {
|
||||||
|
resetReplyState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
focusButton.addEventListener('click', () => {
|
||||||
|
if (!activeParagraphKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor = paragraphDescriptors.get(activeParagraphKey);
|
||||||
|
descriptor?.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!activeParagraphKey) {
|
||||||
|
setStatus(t('paragraphComments.selectedRequired'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor = paragraphDescriptors.get(activeParagraphKey);
|
||||||
|
if (!descriptor) {
|
||||||
|
setStatus(t('paragraphComments.contextMissing'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
clearStatus();
|
||||||
|
setStatus(t('paragraphComments.submitting'), 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBase}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
postSlug,
|
||||||
|
nickname: formData.get('nickname'),
|
||||||
|
email: formData.get('email'),
|
||||||
|
content: formData.get('content'),
|
||||||
|
scope: 'paragraph',
|
||||||
|
paragraphKey: descriptor.key,
|
||||||
|
paragraphExcerpt: descriptor.excerpt,
|
||||||
|
replyToCommentId: activeReplyToCommentId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = pendingComments.get(descriptor.key) || [];
|
||||||
|
pendingCounter += 1;
|
||||||
|
pending.push({
|
||||||
|
id: `pending-${pendingCounter}`,
|
||||||
|
author: String(formData.get('nickname') || t('paragraphComments.anonymous')),
|
||||||
|
content: String(formData.get('content') || ''),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
reply_to_comment_id: activeReplyToCommentId,
|
||||||
|
});
|
||||||
|
pendingComments.set(descriptor.key, pending);
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
resetReplyState();
|
||||||
|
const approvedComments = await loadThread(descriptor.key, false);
|
||||||
|
renderThread(descriptor.key, approvedComments);
|
||||||
|
setStatus(t('paragraphComments.submitSuccess'), 'success');
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (!wrapper || !articleContent || !postSlug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptors = buildParagraphDescriptors(articleContent);
|
||||||
|
if (descriptors.length === 0) {
|
||||||
|
setSummaryMessage(t('paragraphComments.noParagraphs'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptors.forEach(descriptor => {
|
||||||
|
paragraphDescriptors.set(descriptor.key, descriptor);
|
||||||
|
descriptor.element.id = `paragraph-${descriptor.key}`;
|
||||||
|
descriptor.element.dataset.paragraphKey = descriptor.key;
|
||||||
|
descriptor.element.classList.add('paragraph-comment-paragraph');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${apiBase}/comments/paragraphs/summary?${new URLSearchParams({ post_slug: postSlug }).toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = (await response.json()) as SummaryItem[];
|
||||||
|
summary.forEach(item => {
|
||||||
|
paragraphCounts.set(item.paragraph_key, item.count);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load paragraph comment summary:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptors.forEach(descriptor => {
|
||||||
|
const row = createParagraphRow(descriptor.key, descriptor.excerpt);
|
||||||
|
paragraphRows.set(descriptor.key, row);
|
||||||
|
paragraphCounts.set(descriptor.key, paragraphCounts.get(descriptor.key) || 0);
|
||||||
|
descriptor.element.insertAdjacentElement('afterend', row);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateRowState();
|
||||||
|
updateSummaryFromCounts();
|
||||||
|
await openFromHash();
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
void openFromHash();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void init();
|
||||||
|
</script>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { Post } from '../lib/types';
|
import type { Post } from '../lib/types';
|
||||||
import TerminalButton from './ui/TerminalButton.astro';
|
import TerminalButton from './ui/TerminalButton.astro';
|
||||||
import CodeBlock from './CodeBlock.astro';
|
import CodeBlock from './CodeBlock.astro';
|
||||||
|
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||||
import { resolveFileRef, getPostTypeColor } from '../lib/utils';
|
import { resolveFileRef, getPostTypeColor } from '../lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,6 +12,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { post, selectedTag = '', highlightTerm = '' } = Astro.props;
|
const { post, selectedTag = '', highlightTerm = '' } = Astro.props;
|
||||||
|
const { locale, t } = getI18n(Astro);
|
||||||
|
|
||||||
const typeColor = getPostTypeColor(post.type);
|
const typeColor = getPostTypeColor(post.type);
|
||||||
|
|
||||||
@@ -43,20 +45,21 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
|||||||
class="post-card terminal-panel group relative my-3 p-5 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
|
class="post-card terminal-panel group relative my-3 p-5 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
|
||||||
style={`--post-border-color: ${typeColor}`}
|
style={`--post-border-color: ${typeColor}`}
|
||||||
>
|
>
|
||||||
<a href={`/articles/${post.slug}`} class="absolute inset-0 z-0 rounded-[inherit]" aria-label={`阅读 ${post.title}`}></a>
|
|
||||||
<div class="absolute left-0 top-4 bottom-4 w-1 rounded-full opacity-80" style={`background-color: ${typeColor}`}></div>
|
<div class="absolute left-0 top-4 bottom-4 w-1 rounded-full opacity-80" style={`background-color: ${typeColor}`}></div>
|
||||||
|
|
||||||
<div class="relative z-10 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between mb-2 pl-3">
|
<div class="relative z-10 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between mb-2 pl-3">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<span class="w-3 h-3 rounded-full shrink-0" style={`background-color: ${typeColor}`}></span>
|
<span class="w-3 h-3 rounded-full shrink-0" style={`background-color: ${typeColor}`}></span>
|
||||||
<h3
|
<a
|
||||||
class={`font-bold text-[var(--title-color)] ${post.type === 'article' ? 'text-lg' : 'text-base'}`}
|
href={`/articles/${post.slug}`}
|
||||||
set:html={highlightText(post.title, highlightTerm)}
|
class={`inline-flex min-w-0 items-center text-[var(--title-color)] transition hover:text-[var(--primary)] ${post.type === 'article' ? 'text-lg font-bold' : 'text-base font-bold'}`}
|
||||||
/>
|
>
|
||||||
|
<h3 class="truncate" set:html={highlightText(post.title, highlightTerm)} />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[var(--text-secondary)]">
|
<p class="text-sm text-[var(--text-secondary)]">
|
||||||
{post.date} | 阅读时间: {post.readTime}
|
{post.date} | {t('common.readTime')}: {formatReadTime(locale, post.readTime, t)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="terminal-chip shrink-0 text-xs py-1 px-2.5">
|
<span class="terminal-chip shrink-0 text-xs py-1 px-2.5">
|
||||||
@@ -109,4 +112,14 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
|||||||
</TerminalButton>
|
</TerminalButton>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 mt-4 pl-3">
|
||||||
|
<a
|
||||||
|
href={`/articles/${post.slug}`}
|
||||||
|
class="terminal-action-button inline-flex"
|
||||||
|
>
|
||||||
|
<i class="fas fa-angle-right"></i>
|
||||||
|
<span>{t('common.readMore')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import { apiClient } from '../lib/api/client';
|
import { apiClient } from '../lib/api/client';
|
||||||
|
import { getI18n } from '../lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentSlug: string;
|
currentSlug: string;
|
||||||
@@ -8,6 +9,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { currentSlug, currentCategory, currentTags } = Astro.props;
|
const { currentSlug, currentCategory, currentTags } = Astro.props;
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
|
|
||||||
const allPosts = await apiClient.getPosts();
|
const allPosts = await apiClient.getPosts();
|
||||||
|
|
||||||
@@ -43,9 +45,9 @@ const relatedPosts = allPosts
|
|||||||
<i class="fas fa-share-nodes"></i>
|
<i class="fas fa-share-nodes"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold text-[var(--title-color)]">相关文章</h3>
|
<h3 class="text-xl font-semibold text-[var(--title-color)]">{t('relatedPosts.title')}</h3>
|
||||||
<p class="text-sm text-[var(--text-secondary)]">
|
<p class="text-sm text-[var(--text-secondary)]">
|
||||||
基于当前分类与标签关联出的相近内容,延续同一条阅读链路。
|
{t('relatedPosts.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +55,7 @@ const relatedPosts = allPosts
|
|||||||
|
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-wave-square text-[var(--primary)]"></i>
|
<i class="fas fa-wave-square text-[var(--primary)]"></i>
|
||||||
{relatedPosts.length} linked
|
{t('relatedPosts.linked', { count: relatedPosts.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
---
|
---
|
||||||
// Table of Contents Component - Extracts headings from article content
|
// Table of Contents Component - Extracts headings from article content
|
||||||
|
import { getI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
---
|
---
|
||||||
|
|
||||||
<aside id="toc-container" class="hidden w-full shrink-0 lg:block lg:w-72">
|
<aside id="toc-container" class="hidden w-full shrink-0 lg:block lg:w-72">
|
||||||
@@ -14,9 +17,9 @@
|
|||||||
<i class="fas fa-list-ul"></i>
|
<i class="fas fa-list-ul"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-[var(--title-color)]">目录</h3>
|
<h3 class="text-base font-semibold text-[var(--title-color)]">{t('toc.title')}</h3>
|
||||||
<p class="text-xs leading-6 text-[var(--text-secondary)]">
|
<p class="text-xs leading-6 text-[var(--text-secondary)]">
|
||||||
实时跟踪当前文档的标题节点,像终端侧栏一样快速跳转。
|
{t('toc.intro')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
16
frontend/src/env.d.ts
vendored
Normal file
16
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__TERMI_I18N__?: {
|
||||||
|
locale: string;
|
||||||
|
messages: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
__termiTranslate: (
|
||||||
|
key: string,
|
||||||
|
params?: Record<string, string | number | null | undefined>
|
||||||
|
) => string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -4,6 +4,7 @@ import Header from '../components/Header.astro';
|
|||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import BackToTop from '../components/interactive/BackToTop.svelte';
|
import BackToTop from '../components/interactive/BackToTop.svelte';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||||
|
import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -11,6 +12,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = Astro.props;
|
const props = Astro.props;
|
||||||
|
const { locale, messages } = getI18n(Astro);
|
||||||
|
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
|
|
||||||
@@ -22,10 +24,11 @@ try {
|
|||||||
|
|
||||||
const title = props.title || siteSettings.siteTitle;
|
const title = props.title || siteSettings.siteTitle;
|
||||||
const description = props.description || siteSettings.siteDescription;
|
const description = props.description || siteSettings.siteDescription;
|
||||||
|
const i18nPayload = JSON.stringify({ locale, messages });
|
||||||
---
|
---
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang={locale} data-locale={locale}>
|
||||||
<head>
|
<head>
|
||||||
<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" />
|
||||||
@@ -162,6 +165,32 @@ const description = props.description || siteSettings.siteDescription;
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script is:inline define:vars={{ i18nPayload, locale, localeCookieName: LOCALE_COOKIE_NAME, supportedLocales: SUPPORTED_LOCALES }}>
|
||||||
|
window.__TERMI_I18N__ = JSON.parse(i18nPayload);
|
||||||
|
window.__termiTranslate = function(key, params = {}) {
|
||||||
|
const payload = window.__TERMI_I18N__ || { messages: {} };
|
||||||
|
const template = key.split('.').reduce((current, segment) => {
|
||||||
|
if (!current || typeof current !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return current[segment];
|
||||||
|
}, payload.messages);
|
||||||
|
|
||||||
|
if (typeof template !== 'string') {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ''));
|
||||||
|
};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
document.documentElement.dataset.locale = locale;
|
||||||
|
localStorage.setItem('locale', locale);
|
||||||
|
document.cookie = `${localeCookieName}=${encodeURIComponent(locale)};path=/;max-age=31536000;samesite=lax`;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
(function() {
|
(function() {
|
||||||
const theme = localStorage.getItem('theme');
|
const theme = localStorage.getItem('theme');
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export interface Comment {
|
|||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
reply_to: string | null;
|
reply_to: string | null;
|
||||||
|
reply_to_comment_id: number | null;
|
||||||
|
scope: 'article' | 'paragraph';
|
||||||
|
paragraph_key: string | null;
|
||||||
|
paragraph_excerpt: string | null;
|
||||||
approved: boolean | null;
|
approved: boolean | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -42,7 +46,16 @@ export interface CreateCommentInput {
|
|||||||
nickname: string;
|
nickname: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
scope?: 'article' | 'paragraph';
|
||||||
|
paragraphKey?: string;
|
||||||
|
paragraphExcerpt?: string;
|
||||||
replyTo?: string | null;
|
replyTo?: string | null;
|
||||||
|
replyToCommentId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParagraphCommentSummary {
|
||||||
|
paragraph_key: string;
|
||||||
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiTag {
|
export interface ApiTag {
|
||||||
@@ -98,6 +111,23 @@ export interface ApiSiteSettings {
|
|||||||
social_email: string | null;
|
social_email: string | null;
|
||||||
location: string | null;
|
location: string | null;
|
||||||
tech_stack: string[] | null;
|
tech_stack: string[] | null;
|
||||||
|
ai_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSource {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
excerpt: string;
|
||||||
|
score: number;
|
||||||
|
chunk_index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiAskResponse {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
sources: AiSource[];
|
||||||
|
indexed_chunks: number;
|
||||||
|
last_indexed_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiSearchResult {
|
export interface ApiSearchResult {
|
||||||
@@ -153,6 +183,9 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
|||||||
email: 'mailto:hello@termi.dev',
|
email: 'mailto:hello@termi.dev',
|
||||||
},
|
},
|
||||||
techStack: ['Astro', 'Svelte', 'Tailwind CSS', 'TypeScript'],
|
techStack: ['Astro', 'Svelte', 'Tailwind CSS', 'TypeScript'],
|
||||||
|
ai: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
|
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
|
||||||
@@ -244,6 +277,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
|
|||||||
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
|
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
|
||||||
},
|
},
|
||||||
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
|
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
|
||||||
|
ai: {
|
||||||
|
enabled: Boolean(settings.ai_enabled),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -293,14 +329,32 @@ class ApiClient {
|
|||||||
return posts.find(post => post.slug === slug) || null;
|
return posts.find(post => post.slug === slug) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getComments(postSlug: string, options?: { approved?: boolean }): Promise<Comment[]> {
|
async getComments(
|
||||||
|
postSlug: string,
|
||||||
|
options?: {
|
||||||
|
approved?: boolean;
|
||||||
|
scope?: 'article' | 'paragraph';
|
||||||
|
paragraphKey?: string;
|
||||||
|
}
|
||||||
|
): Promise<Comment[]> {
|
||||||
const params = new URLSearchParams({ post_slug: postSlug });
|
const params = new URLSearchParams({ post_slug: postSlug });
|
||||||
if (options?.approved !== undefined) {
|
if (options?.approved !== undefined) {
|
||||||
params.set('approved', String(options.approved));
|
params.set('approved', String(options.approved));
|
||||||
}
|
}
|
||||||
|
if (options?.scope) {
|
||||||
|
params.set('scope', options.scope);
|
||||||
|
}
|
||||||
|
if (options?.paragraphKey) {
|
||||||
|
params.set('paragraph_key', options.paragraphKey);
|
||||||
|
}
|
||||||
return this.fetch<Comment[]>(`/comments?${params.toString()}`);
|
return this.fetch<Comment[]>(`/comments?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getParagraphCommentSummary(postSlug: string): Promise<ParagraphCommentSummary[]> {
|
||||||
|
const params = new URLSearchParams({ post_slug: postSlug });
|
||||||
|
return this.fetch<ParagraphCommentSummary[]>(`/comments/paragraphs/summary?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
async createComment(comment: CreateCommentInput): Promise<Comment> {
|
async createComment(comment: CreateCommentInput): Promise<Comment> {
|
||||||
return this.fetch<Comment>('/comments', {
|
return this.fetch<Comment>('/comments', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -309,7 +363,11 @@ class ApiClient {
|
|||||||
nickname: comment.nickname,
|
nickname: comment.nickname,
|
||||||
email: comment.email,
|
email: comment.email,
|
||||||
content: comment.content,
|
content: comment.content,
|
||||||
|
scope: comment.scope,
|
||||||
|
paragraphKey: comment.paragraphKey,
|
||||||
|
paragraphExcerpt: comment.paragraphExcerpt,
|
||||||
replyTo: comment.replyTo,
|
replyTo: comment.replyTo,
|
||||||
|
replyToCommentId: comment.replyToCommentId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -398,6 +456,13 @@ class ApiClient {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async askAi(question: string): Promise<AiAskResponse> {
|
||||||
|
return this.fetch<AiAskResponse>('/ai/ask', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ question }),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiClient(API_BASE_URL);
|
export const api = new ApiClient(API_BASE_URL);
|
||||||
|
|||||||
144
frontend/src/lib/i18n/index.ts
Normal file
144
frontend/src/lib/i18n/index.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { messages } from './messages';
|
||||||
|
|
||||||
|
export const SUPPORTED_LOCALES = ['zh-CN', 'en'] as const;
|
||||||
|
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
export const DEFAULT_LOCALE: Locale = 'zh-CN';
|
||||||
|
export const LOCALE_COOKIE_NAME = 'termi_locale';
|
||||||
|
|
||||||
|
type TranslateParams = Record<string, string | number | null | undefined>;
|
||||||
|
|
||||||
|
function resolveMessage(locale: Locale, key: string): string | undefined {
|
||||||
|
const segments = key.split('.');
|
||||||
|
let current: unknown = messages[locale];
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (!current || typeof current !== 'object' || !(segment in current)) {
|
||||||
|
current = undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = (current as Record<string, unknown>)[segment];
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof current === 'string' ? current : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(template: string, params?: TranslateParams): string {
|
||||||
|
if (!params) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLocale(value: string | null | undefined): Locale | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('zh')) {
|
||||||
|
return 'zh-CN';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith('en')) {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLocale(options: {
|
||||||
|
query?: string | null;
|
||||||
|
cookie?: string | null;
|
||||||
|
acceptLanguage?: string | null;
|
||||||
|
}): Locale {
|
||||||
|
const fromQuery = normalizeLocale(options.query);
|
||||||
|
if (fromQuery) {
|
||||||
|
return fromQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromCookie = normalizeLocale(options.cookie);
|
||||||
|
if (fromCookie) {
|
||||||
|
return fromCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptLanguages = String(options.acceptLanguage || '')
|
||||||
|
.split(',')
|
||||||
|
.map((part) => normalizeLocale(part.split(';')[0]))
|
||||||
|
.filter(Boolean) as Locale[];
|
||||||
|
|
||||||
|
return acceptLanguages[0] || DEFAULT_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translate(locale: Locale, key: string, params?: TranslateParams): string {
|
||||||
|
const template =
|
||||||
|
resolveMessage(locale, key) ??
|
||||||
|
resolveMessage(DEFAULT_LOCALE, key) ??
|
||||||
|
key;
|
||||||
|
|
||||||
|
return interpolate(template, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessages(locale: Locale) {
|
||||||
|
return messages[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLocaleUrl(url: URL, locale: Locale): string {
|
||||||
|
const next = new URL(url);
|
||||||
|
next.searchParams.set('lang', locale);
|
||||||
|
return `${next.pathname}${next.search}${next.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getI18n(Astro: {
|
||||||
|
url: URL;
|
||||||
|
request: Request;
|
||||||
|
cookies?: {
|
||||||
|
get?: (name: string) => { value: string } | undefined;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const requestUrl = new URL(Astro.request.url);
|
||||||
|
const locale = resolveLocale({
|
||||||
|
query: requestUrl.searchParams.get('lang'),
|
||||||
|
cookie: Astro.cookies?.get?.(LOCALE_COOKIE_NAME)?.value ?? null,
|
||||||
|
acceptLanguage: Astro.request.headers.get('accept-language'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const t = (key: string, params?: TranslateParams) => translate(locale, key, params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
t,
|
||||||
|
messages: getMessages(locale),
|
||||||
|
buildLocaleUrl: (targetLocale: Locale) => buildLocaleUrl(requestUrl, targetLocale),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReadTimeMinutes(value: string | number | null | undefined): number | null {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = String(value || '').match(/\d+/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(match[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatReadTime(
|
||||||
|
_locale: Locale,
|
||||||
|
value: string | number | null | undefined,
|
||||||
|
t: (key: string, params?: TranslateParams) => string
|
||||||
|
): string {
|
||||||
|
const minutes = getReadTimeMinutes(value);
|
||||||
|
if (minutes === null) {
|
||||||
|
return String(value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('common.readTimeMinutes', { count: minutes });
|
||||||
|
}
|
||||||
696
frontend/src/lib/i18n/messages.ts
Normal file
696
frontend/src/lib/i18n/messages.ts
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
export const messages = {
|
||||||
|
'zh-CN': {
|
||||||
|
common: {
|
||||||
|
language: '语言',
|
||||||
|
languages: {
|
||||||
|
'zh-CN': '简体中文',
|
||||||
|
en: 'English',
|
||||||
|
},
|
||||||
|
all: '全部',
|
||||||
|
search: '搜索',
|
||||||
|
ai: 'AI',
|
||||||
|
article: '文章',
|
||||||
|
tweet: '动态',
|
||||||
|
posts: '文章',
|
||||||
|
tags: '标签',
|
||||||
|
categories: '分类',
|
||||||
|
friends: '友链',
|
||||||
|
location: '位置',
|
||||||
|
unknown: '未知',
|
||||||
|
other: '其他',
|
||||||
|
current: '当前',
|
||||||
|
readTime: '阅读时间',
|
||||||
|
readTimeMinutes: '{count} 分钟',
|
||||||
|
characters: '{count} 字',
|
||||||
|
postsCount: '{count} 篇',
|
||||||
|
tagsCount: '{count} 个标签',
|
||||||
|
categoriesCount: '{count} 个分类',
|
||||||
|
friendsCount: '{count} 个友链',
|
||||||
|
reviewsCount: '{count} 条评价',
|
||||||
|
resultsCount: '{count} 条结果',
|
||||||
|
reviewedOnly: '仅展示已通过审核',
|
||||||
|
noData: '暂无数据',
|
||||||
|
noResults: '没有匹配结果',
|
||||||
|
open: '打开',
|
||||||
|
close: '关闭',
|
||||||
|
submit: '提交',
|
||||||
|
cancel: '取消',
|
||||||
|
clear: '清除',
|
||||||
|
reset: '重置',
|
||||||
|
reply: '回复',
|
||||||
|
like: '点赞',
|
||||||
|
visit: '访问',
|
||||||
|
readMore: '阅读全文',
|
||||||
|
viewArticle: '打开文章',
|
||||||
|
viewAllArticles: '查看所有文章',
|
||||||
|
viewAllLinks: '查看全部友链',
|
||||||
|
viewCategoryArticles: '查看分类文章',
|
||||||
|
clearFilters: '清除筛选',
|
||||||
|
resetFilters: '重置筛选',
|
||||||
|
home: '首页',
|
||||||
|
browsePosts: '浏览文章',
|
||||||
|
goBack: '返回上一页',
|
||||||
|
backToIndex: '返回索引',
|
||||||
|
copyPermalink: '复制固定链接',
|
||||||
|
locateParagraph: '定位段落',
|
||||||
|
maxChars: '最多 {count} 字',
|
||||||
|
optional: '可选',
|
||||||
|
pending: '待审核',
|
||||||
|
approved: '已审核',
|
||||||
|
completed: '已完成',
|
||||||
|
inProgress: '进行中',
|
||||||
|
featureOn: '功能已开启',
|
||||||
|
featureOff: '功能未开启',
|
||||||
|
emptyState: '当前还没有内容。',
|
||||||
|
apiUnavailable: 'API 暂时不可用',
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
articles: '文章',
|
||||||
|
categories: '分类',
|
||||||
|
tags: '标签',
|
||||||
|
timeline: '时间轴',
|
||||||
|
reviews: '评价',
|
||||||
|
friends: '友链',
|
||||||
|
about: '关于',
|
||||||
|
ask: 'AI 问答',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
navigation: '导航',
|
||||||
|
themeToggle: '切换主题',
|
||||||
|
toggleMenu: '切换菜单',
|
||||||
|
searchModeKeyword: '搜索',
|
||||||
|
searchModeAi: 'AI',
|
||||||
|
searchModeKeywordMobile: '关键词搜索',
|
||||||
|
searchModeAiMobile: 'AI 搜索',
|
||||||
|
searchPlaceholderKeyword: "'关键词'",
|
||||||
|
searchPlaceholderAi: '输入问题,交给站内 AI',
|
||||||
|
searchHintKeyword: 'articles/*.md',
|
||||||
|
searchHintAi: '手动确认',
|
||||||
|
aiModeTitle: 'AI 问答模式',
|
||||||
|
aiModeHeading: '把这个问题交给站内 AI',
|
||||||
|
aiModeDescription: 'AI 会先检索站内知识库,再给出总结式回答,并附带相关文章来源。',
|
||||||
|
aiModeNotice: '进入问答页后不会自动调用模型,需要你手动确认发送。',
|
||||||
|
aiModeCta: '前往 AI 问答页确认',
|
||||||
|
liveResults: '实时搜索结果',
|
||||||
|
searching: '正在搜索 {query} ...',
|
||||||
|
searchFailed: '搜索失败,请稍后再试。',
|
||||||
|
searchEmpty: '没有找到和 {query} 相关的内容。',
|
||||||
|
searchEmptyCta: '去 AI 问答页确认提问',
|
||||||
|
searchAiFooter: '去 AI 问答页手动确认',
|
||||||
|
searchAllResults: '查看全部搜索结果',
|
||||||
|
untitled: '未命名',
|
||||||
|
manualConfirm: 'AI 必须手动确认后才会提问',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
session: '会话',
|
||||||
|
copyright: '© {year} {site}. 保留所有权利。',
|
||||||
|
sitemap: '站点地图',
|
||||||
|
rss: 'RSS 订阅',
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
pinned: '置顶',
|
||||||
|
about: '关于我',
|
||||||
|
techStack: '技术栈',
|
||||||
|
systemStatus: '系统状态',
|
||||||
|
},
|
||||||
|
articlesPage: {
|
||||||
|
title: '文章索引',
|
||||||
|
description: '按类型、分类和标签筛选内容,快速浏览整个内容目录。',
|
||||||
|
totalPosts: '共 {count} 篇',
|
||||||
|
allCategories: '全部分类',
|
||||||
|
allTags: '全部标签',
|
||||||
|
emptyTitle: '没有匹配结果',
|
||||||
|
emptyDescription: '当前筛选条件下没有找到文章。可以清空标签或关键字,重新浏览整个内容目录。',
|
||||||
|
pageSummary: '第 {current} / {total} 页 · 共 {count} 条结果',
|
||||||
|
previous: '上一页',
|
||||||
|
next: '下一页',
|
||||||
|
},
|
||||||
|
article: {
|
||||||
|
backToArticles: '返回文章索引',
|
||||||
|
documentSession: '文档会话',
|
||||||
|
filePath: '文件路径',
|
||||||
|
},
|
||||||
|
relatedPosts: {
|
||||||
|
title: '相关文章',
|
||||||
|
description: '基于当前分类与标签关联出的相近内容,延续同一条阅读链路。',
|
||||||
|
linked: '{count} 条关联',
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
title: '评论终端',
|
||||||
|
description: '这里是整篇文章的讨论区,当前缓冲区共有 {count} 条已展示评论,新的留言提交后会进入审核队列。',
|
||||||
|
writeComment: '写评论',
|
||||||
|
nickname: '昵称',
|
||||||
|
email: '邮箱',
|
||||||
|
message: '内容',
|
||||||
|
messagePlaceholder: "$ echo '留下你的想法...'",
|
||||||
|
maxChars: '最多 500 字',
|
||||||
|
cancelReply: '取消回复',
|
||||||
|
emptyTitle: '暂无评论',
|
||||||
|
emptyDescription: '当前还没有留言。可以打开上面的输入面板,成为第一个在这个终端缓冲区里发言的人。',
|
||||||
|
today: '今天',
|
||||||
|
yesterday: '昨天',
|
||||||
|
daysAgo: '{count} 天前',
|
||||||
|
weeksAgo: '{count} 周前',
|
||||||
|
anonymous: '匿名',
|
||||||
|
submitting: '正在提交评论...',
|
||||||
|
submitSuccess: '评论已提交,审核通过后会显示在这里。',
|
||||||
|
submitFailed: '提交失败:{message}',
|
||||||
|
loadFailed: '加载评论失败',
|
||||||
|
noSelection: '当前没有选中的评论。',
|
||||||
|
},
|
||||||
|
paragraphComments: {
|
||||||
|
title: '段落评论已启用',
|
||||||
|
intro: '正文里的自然段都会挂一个轻量讨论入口,适合只针对某一段补充上下文、指出问题或继续展开讨论。',
|
||||||
|
scanning: '正在扫描段落缓冲区...',
|
||||||
|
noParagraphs: '当前文章没有可挂载评论的自然段。',
|
||||||
|
summary: '已为 {paragraphCount} 个自然段挂载评论入口,其中 {discussedCount} 段已有讨论,当前共展示 {approvedCount} 条已审核段落评论。',
|
||||||
|
focusCurrent: '聚焦当前段落',
|
||||||
|
panelTitle: '段落讨论面板',
|
||||||
|
close: '关闭',
|
||||||
|
nickname: '昵称',
|
||||||
|
email: '邮箱',
|
||||||
|
comment: '评论',
|
||||||
|
commentPlaceholder: "$ echo '只评论这一段...'",
|
||||||
|
maxChars: '最多 500 字',
|
||||||
|
clearReply: '清除回复',
|
||||||
|
replyTo: '回复给',
|
||||||
|
approvedThread: '公开讨论',
|
||||||
|
pendingQueue: '待审核队列',
|
||||||
|
emptyTitle: '这段还没有公开评论',
|
||||||
|
emptyDescription: '适合补充上下文、指出细节或者提出具体问题。新的留言提交后会先进入审核队列。',
|
||||||
|
loadingThread: '正在拉取该段的已审核评论...',
|
||||||
|
loadFailedShort: '加载失败',
|
||||||
|
loadFailed: '加载失败:{message}',
|
||||||
|
selectedRequired: '当前没有选中的段落。',
|
||||||
|
contextMissing: '段落上下文丢失,请重新打开该段评论面板。',
|
||||||
|
submitting: '正在提交段落评论...',
|
||||||
|
submitSuccess: '评论已提交,已先放入本地待审核队列;审核通过后会进入公开讨论。',
|
||||||
|
submitFailed: '提交失败:{message}',
|
||||||
|
anonymous: '匿名',
|
||||||
|
oneNote: '1 条评论',
|
||||||
|
manyNotes: '{count} 条评论',
|
||||||
|
zeroNotes: '评论',
|
||||||
|
waitingReview: '等待审核',
|
||||||
|
locateParagraph: '定位段落',
|
||||||
|
},
|
||||||
|
ask: {
|
||||||
|
pageTitle: 'AI 问答',
|
||||||
|
pageDescription: '基于 {siteName} 内容知识库的站内 AI 问答',
|
||||||
|
title: 'AI 站内问答',
|
||||||
|
subtitle: '基于博客 Markdown 内容建立索引,回答会优先引用站内真实资料。',
|
||||||
|
disabledTitle: '后台暂未开启 AI 问答',
|
||||||
|
disabledDescription: '这个入口已经接好了真实后端,但当前站点设置里没有开启公开问答。管理员开启后,这里会自动变成可用状态,导航也会同步显示。',
|
||||||
|
textareaPlaceholder: '输入你想问的问题,比如:这个博客关于前端写过哪些内容?',
|
||||||
|
submit: '开始提问',
|
||||||
|
idleStatus: '知识库已接入,等待问题输入。',
|
||||||
|
examples: '示例问题',
|
||||||
|
workflow: '工作流',
|
||||||
|
workflow1: '1. 后台开启 AI 开关并配置聊天模型。',
|
||||||
|
workflow2: '2. 重建索引,把 Markdown 文章切块后由后端本地生成 embedding,并写入 PostgreSQL pgvector。',
|
||||||
|
workflow3: '3. 前台提问时先在 pgvector 中做相似度检索,再交给聊天模型基于上下文回答。',
|
||||||
|
emptyAnswer: '暂无回答。',
|
||||||
|
requestFailed: '请求失败:{message}',
|
||||||
|
streamUnsupported: '当前浏览器无法读取流式响应。',
|
||||||
|
enterQuestion: '先输入一个问题。',
|
||||||
|
cacheRestored: '已从当前会话缓存中恢复回答。',
|
||||||
|
connecting: '正在建立流式连接,请稍候...',
|
||||||
|
processing: '正在处理请求...',
|
||||||
|
complete: '回答已生成。',
|
||||||
|
streamFailed: '流式请求失败',
|
||||||
|
streamInterrupted: '流式响应被提前中断。',
|
||||||
|
retryLater: '这次请求没有成功,可以稍后重试。',
|
||||||
|
prefixedQuestion: '已带入搜索词,确认后开始提问。',
|
||||||
|
sources: '来源',
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
pageTitle: '关于',
|
||||||
|
title: '关于我',
|
||||||
|
intro: '这里汇总站点主人、技术栈、系统状态和联系方式,并与全站语言设置保持一致。',
|
||||||
|
techStackCount: '{count} 项技术栈',
|
||||||
|
profile: '身份档案',
|
||||||
|
contact: '联系方式',
|
||||||
|
website: '网站',
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
pageTitle: '分类',
|
||||||
|
title: '文章分类',
|
||||||
|
intro: '按内容主题浏览文章,分类页现在和其他列表页保持同一套终端面板语言。',
|
||||||
|
quickJump: '快速跳转分类文章',
|
||||||
|
categoryPosts: '浏览 {name} 主题下的全部文章和更新记录。',
|
||||||
|
empty: '暂无分类数据',
|
||||||
|
},
|
||||||
|
friends: {
|
||||||
|
pageTitle: '友情链接',
|
||||||
|
pageDescription: '与 {siteName} 交换友情链接',
|
||||||
|
title: '友情链接',
|
||||||
|
intro: '这里聚合已经通过审核的站点,也提供统一风格的申请面板,避免列表区和表单区像两个页面。',
|
||||||
|
collection: '友链分组',
|
||||||
|
exchangeRules: '友链交换',
|
||||||
|
exchangeIntro: '欢迎交换友情链接,请确保您的网站满足以下条件:',
|
||||||
|
rule1: '原创内容为主',
|
||||||
|
rule2: '网站稳定运行',
|
||||||
|
rule3: '无不良内容',
|
||||||
|
siteInfo: '本站信息:',
|
||||||
|
name: '名称',
|
||||||
|
description: '描述',
|
||||||
|
link: '链接',
|
||||||
|
},
|
||||||
|
friendForm: {
|
||||||
|
title: '提交友链申请',
|
||||||
|
intro: '填写站点信息后会提交到后台审核,审核通过后前台会自动展示。',
|
||||||
|
reviewedOnline: '后台审核后上线',
|
||||||
|
siteName: '站点名称',
|
||||||
|
siteUrl: '站点链接',
|
||||||
|
avatarUrl: '头像链接',
|
||||||
|
category: '分类',
|
||||||
|
description: '站点描述',
|
||||||
|
reciprocal: '已添加本站友链',
|
||||||
|
reciprocalHint: '这是提交申请前的必要条件。',
|
||||||
|
copy: '复制',
|
||||||
|
copied: '已复制',
|
||||||
|
submit: '提交申请',
|
||||||
|
reset: '重置',
|
||||||
|
addReciprocalFirst: '请先添加本站友链后再提交申请。',
|
||||||
|
submitting: '正在提交友链申请...',
|
||||||
|
submitSuccess: '友链申请已提交,我们会尽快审核。',
|
||||||
|
submitFailed: '提交失败:{message}',
|
||||||
|
categoryTech: '技术',
|
||||||
|
categoryLife: '生活',
|
||||||
|
categoryDesign: '设计',
|
||||||
|
categoryOther: '其他',
|
||||||
|
descriptionPlaceholder: '简要介绍一下你的网站...',
|
||||||
|
},
|
||||||
|
friendCard: {
|
||||||
|
externalLink: '外部链接',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
pageTitle: '标签',
|
||||||
|
title: '标签云',
|
||||||
|
intro: '用更轻量的关键词维度检索文章。选中标签时,下方结果区会延续同一套终端卡片风格。',
|
||||||
|
currentTag: '当前: #{tag}',
|
||||||
|
selectedSummary: '标签 #{tag} 找到 {count} 篇文章',
|
||||||
|
browseTags: '浏览标签',
|
||||||
|
emptyTags: '暂无标签数据',
|
||||||
|
emptyPosts: '没有找到该标签的文章',
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
pageTitle: '时间轴',
|
||||||
|
pageDescription: '记录 {ownerName} 的技术成长与生活点滴',
|
||||||
|
title: '时间轴',
|
||||||
|
subtitle: '共 {count} 篇内容 · 记录 {ownerName} 的技术成长与生活点滴',
|
||||||
|
allYears: '全部',
|
||||||
|
},
|
||||||
|
reviews: {
|
||||||
|
pageTitle: '评价',
|
||||||
|
pageDescription: '记录游戏、音乐、动画、书籍与影视的体验和评价',
|
||||||
|
title: '评价',
|
||||||
|
subtitle: '记录游戏、音乐、动画、书籍与影视的体验和感悟',
|
||||||
|
total: '总评价',
|
||||||
|
average: '平均评分',
|
||||||
|
completed: '已完成',
|
||||||
|
inProgress: '进行中',
|
||||||
|
emptyData: '暂无评价数据,请检查后端 API 连接',
|
||||||
|
emptyFiltered: '当前筛选下暂无评价',
|
||||||
|
currentFilter: '当前筛选: {type}',
|
||||||
|
typeAll: '全部',
|
||||||
|
typeGame: '游戏',
|
||||||
|
typeAnime: '动画',
|
||||||
|
typeMusic: '音乐',
|
||||||
|
typeBook: '书籍',
|
||||||
|
typeMovie: '影视',
|
||||||
|
},
|
||||||
|
notFound: {
|
||||||
|
pageTitle: '页面未找到',
|
||||||
|
pageDescription: '您访问的页面不存在',
|
||||||
|
title: '404 - 页面未找到',
|
||||||
|
intro: '当前请求没有命中任何内容节点。下面保留了终端化错误信息、可执行操作,以及可回退到的真实文章入口。',
|
||||||
|
terminalLog: '终端错误日志',
|
||||||
|
requestedRouteNotFound: '错误:请求的路由不存在',
|
||||||
|
path: '路径',
|
||||||
|
time: '时间',
|
||||||
|
actions: '可执行操作',
|
||||||
|
actionsIntro: '像命令面板一样,优先给出直接可走的恢复路径。',
|
||||||
|
searchHint: '也可以直接使用顶部的搜索输入框,在 `articles/*.md` 里重新 grep 一次相关关键字。',
|
||||||
|
recommended: '推荐入口',
|
||||||
|
recommendedIntro: '使用真实文章数据,避免 404 页面再把人带进不存在的地址。',
|
||||||
|
cannotLoad: '暂时无法读取文章列表。',
|
||||||
|
},
|
||||||
|
toc: {
|
||||||
|
title: '目录',
|
||||||
|
intro: '实时跟踪当前文档的标题节点,像终端侧栏一样快速跳转。',
|
||||||
|
},
|
||||||
|
codeCopy: {
|
||||||
|
copy: '复制',
|
||||||
|
copied: '已复制',
|
||||||
|
failed: '失败',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
common: {
|
||||||
|
language: 'Language',
|
||||||
|
languages: {
|
||||||
|
'zh-CN': '简体中文',
|
||||||
|
en: 'English',
|
||||||
|
},
|
||||||
|
all: 'All',
|
||||||
|
search: 'Search',
|
||||||
|
ai: 'AI',
|
||||||
|
article: 'Article',
|
||||||
|
tweet: 'Update',
|
||||||
|
posts: 'Posts',
|
||||||
|
tags: 'Tags',
|
||||||
|
categories: 'Categories',
|
||||||
|
friends: 'Links',
|
||||||
|
location: 'Location',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
other: 'Other',
|
||||||
|
current: 'Current',
|
||||||
|
readTime: 'Read time',
|
||||||
|
readTimeMinutes: '{count} min read',
|
||||||
|
characters: '{count} chars',
|
||||||
|
postsCount: '{count} posts',
|
||||||
|
tagsCount: '{count} tags',
|
||||||
|
categoriesCount: '{count} categories',
|
||||||
|
friendsCount: '{count} links',
|
||||||
|
reviewsCount: '{count} reviews',
|
||||||
|
resultsCount: '{count} results',
|
||||||
|
reviewedOnly: 'Approved links only',
|
||||||
|
noData: 'No data yet',
|
||||||
|
noResults: 'No matching results',
|
||||||
|
open: 'Open',
|
||||||
|
close: 'Close',
|
||||||
|
submit: 'Submit',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
clear: 'Clear',
|
||||||
|
reset: 'Reset',
|
||||||
|
reply: 'Reply',
|
||||||
|
like: 'Like',
|
||||||
|
visit: 'Visit',
|
||||||
|
readMore: 'Read more',
|
||||||
|
viewArticle: 'Open article',
|
||||||
|
viewAllArticles: 'View all articles',
|
||||||
|
viewAllLinks: 'View all links',
|
||||||
|
viewCategoryArticles: 'View category posts',
|
||||||
|
clearFilters: 'Clear filters',
|
||||||
|
resetFilters: 'Reset filters',
|
||||||
|
home: 'Home',
|
||||||
|
browsePosts: 'Browse posts',
|
||||||
|
goBack: 'Go back',
|
||||||
|
backToIndex: 'Back to index',
|
||||||
|
copyPermalink: 'Copy permalink',
|
||||||
|
locateParagraph: 'Locate paragraph',
|
||||||
|
maxChars: 'Max {count} chars',
|
||||||
|
optional: 'Optional',
|
||||||
|
pending: 'Pending',
|
||||||
|
approved: 'Approved',
|
||||||
|
completed: 'Completed',
|
||||||
|
inProgress: 'In progress',
|
||||||
|
featureOn: 'Feature on',
|
||||||
|
featureOff: 'Feature off',
|
||||||
|
emptyState: 'Nothing here yet.',
|
||||||
|
apiUnavailable: 'API temporarily unavailable',
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
articles: 'Articles',
|
||||||
|
categories: 'Categories',
|
||||||
|
tags: 'Tags',
|
||||||
|
timeline: 'Timeline',
|
||||||
|
reviews: 'Reviews',
|
||||||
|
friends: 'Links',
|
||||||
|
about: 'About',
|
||||||
|
ask: 'Ask AI',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
navigation: 'Navigation',
|
||||||
|
themeToggle: 'Toggle theme',
|
||||||
|
toggleMenu: 'Toggle menu',
|
||||||
|
searchModeKeyword: 'Search',
|
||||||
|
searchModeAi: 'AI',
|
||||||
|
searchModeKeywordMobile: 'Keyword Search',
|
||||||
|
searchModeAiMobile: 'AI Search',
|
||||||
|
searchPlaceholderKeyword: "'keyword'",
|
||||||
|
searchPlaceholderAi: 'Type a question for the site AI',
|
||||||
|
searchHintKeyword: 'articles/*.md',
|
||||||
|
searchHintAi: 'manual confirm',
|
||||||
|
aiModeTitle: 'AI Q&A mode',
|
||||||
|
aiModeHeading: 'Send this question to the site AI',
|
||||||
|
aiModeDescription: 'The AI will search the site knowledge base first, then answer with source-backed summaries.',
|
||||||
|
aiModeNotice: 'The model will not run automatically after navigation. You must confirm manually.',
|
||||||
|
aiModeCta: 'Open AI Q&A to confirm',
|
||||||
|
liveResults: 'Live results',
|
||||||
|
searching: 'Searching {query} ...',
|
||||||
|
searchFailed: 'Search failed. Please try again later.',
|
||||||
|
searchEmpty: 'No content matched {query}.',
|
||||||
|
searchEmptyCta: 'Ask AI instead',
|
||||||
|
searchAiFooter: 'Open AI Q&A manually',
|
||||||
|
searchAllResults: 'View all results',
|
||||||
|
untitled: 'Untitled',
|
||||||
|
manualConfirm: 'AI questions must be confirmed manually',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
session: 'Session',
|
||||||
|
copyright: '© {year} {site}. All rights reserved.',
|
||||||
|
sitemap: 'Sitemap',
|
||||||
|
rss: 'RSS feed',
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
pinned: 'Pinned',
|
||||||
|
about: 'About',
|
||||||
|
techStack: 'Tech stack',
|
||||||
|
systemStatus: 'System status',
|
||||||
|
},
|
||||||
|
articlesPage: {
|
||||||
|
title: 'Article Index',
|
||||||
|
description: 'Filter content by type, category, and tag to browse the full archive quickly.',
|
||||||
|
totalPosts: '{count} posts',
|
||||||
|
allCategories: 'All categories',
|
||||||
|
allTags: 'All tags',
|
||||||
|
emptyTitle: 'No matching results',
|
||||||
|
emptyDescription: 'No posts matched the current filters. Clear a tag or keyword to browse the full archive again.',
|
||||||
|
pageSummary: 'Page {current}/{total} · {count} results',
|
||||||
|
previous: 'Prev',
|
||||||
|
next: 'Next',
|
||||||
|
},
|
||||||
|
article: {
|
||||||
|
backToArticles: 'Back to article index',
|
||||||
|
documentSession: 'Document session',
|
||||||
|
filePath: 'File path',
|
||||||
|
},
|
||||||
|
relatedPosts: {
|
||||||
|
title: 'Related Posts',
|
||||||
|
description: 'More nearby reading paths based on the current category and shared tags.',
|
||||||
|
linked: '{count} linked',
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
title: 'Comment Terminal',
|
||||||
|
description: 'This is the discussion thread for the whole article. {count} approved comments are shown right now, and new messages enter moderation first.',
|
||||||
|
writeComment: 'Write comment',
|
||||||
|
nickname: 'Nickname',
|
||||||
|
email: 'Email',
|
||||||
|
message: 'Message',
|
||||||
|
messagePlaceholder: "$ echo 'Leave your thoughts here...'",
|
||||||
|
maxChars: 'Max 500 chars',
|
||||||
|
cancelReply: 'Cancel reply',
|
||||||
|
emptyTitle: 'No comments yet',
|
||||||
|
emptyDescription: 'No one has posted here yet. Open the input panel above and be the first voice in this buffer.',
|
||||||
|
today: 'Today',
|
||||||
|
yesterday: 'Yesterday',
|
||||||
|
daysAgo: '{count} days ago',
|
||||||
|
weeksAgo: '{count} weeks ago',
|
||||||
|
anonymous: 'Anonymous',
|
||||||
|
submitting: 'Submitting comment...',
|
||||||
|
submitSuccess: 'Comment submitted. It will appear here after moderation.',
|
||||||
|
submitFailed: 'Submit failed: {message}',
|
||||||
|
loadFailed: 'Failed to load comments',
|
||||||
|
noSelection: 'No comment is selected.',
|
||||||
|
},
|
||||||
|
paragraphComments: {
|
||||||
|
title: 'Paragraph comments are enabled',
|
||||||
|
intro: 'Each natural paragraph in the article gets a lightweight discussion entry point, perfect for focused context, corrections, or follow-up questions.',
|
||||||
|
scanning: 'Scanning paragraph buffer...',
|
||||||
|
noParagraphs: 'No commentable paragraphs were found in this article.',
|
||||||
|
summary: '{paragraphCount} paragraphs have comment entries, {discussedCount} already have discussion, and {approvedCount} approved paragraph comments are currently visible.',
|
||||||
|
focusCurrent: 'Focus current paragraph',
|
||||||
|
panelTitle: 'Paragraph discussion panel',
|
||||||
|
close: 'Close',
|
||||||
|
nickname: 'Nickname',
|
||||||
|
email: 'Email',
|
||||||
|
comment: 'Comment',
|
||||||
|
commentPlaceholder: "$ echo 'Comment on this paragraph only...'",
|
||||||
|
maxChars: 'Max 500 chars',
|
||||||
|
clearReply: 'Clear reply',
|
||||||
|
replyTo: 'Reply to',
|
||||||
|
approvedThread: 'Approved thread',
|
||||||
|
pendingQueue: 'Pending queue',
|
||||||
|
emptyTitle: 'No public comments on this paragraph yet',
|
||||||
|
emptyDescription: 'Use this space to add context, point out details, or ask a specific question. New comments go through moderation first.',
|
||||||
|
loadingThread: 'Loading approved comments for this paragraph...',
|
||||||
|
loadFailedShort: 'Load failed',
|
||||||
|
loadFailed: 'Load failed: {message}',
|
||||||
|
selectedRequired: 'No paragraph is currently selected.',
|
||||||
|
contextMissing: 'Paragraph context was lost. Please reopen the paragraph panel.',
|
||||||
|
submitting: 'Submitting paragraph comment...',
|
||||||
|
submitSuccess: 'Comment submitted. It has been placed into the local pending queue and will join the public thread after moderation.',
|
||||||
|
submitFailed: 'Submit failed: {message}',
|
||||||
|
anonymous: 'Anonymous',
|
||||||
|
oneNote: '1 note',
|
||||||
|
manyNotes: '{count} notes',
|
||||||
|
zeroNotes: 'comment',
|
||||||
|
waitingReview: 'waiting review',
|
||||||
|
locateParagraph: 'Locate paragraph',
|
||||||
|
},
|
||||||
|
ask: {
|
||||||
|
pageTitle: 'Ask AI',
|
||||||
|
pageDescription: 'An on-site AI Q&A experience grounded in the {siteName} knowledge base',
|
||||||
|
title: 'On-site AI Q&A',
|
||||||
|
subtitle: 'Answers are grounded in indexed Markdown content from the blog and prioritize real on-site references.',
|
||||||
|
disabledTitle: 'AI Q&A is not enabled yet',
|
||||||
|
disabledDescription: 'The real backend integration is already in place, but public Q&A is still disabled in site settings. Once it is enabled, this page and the navigation entry will become available automatically.',
|
||||||
|
textareaPlaceholder: 'Ask anything, for example: what has this blog written about frontend topics?',
|
||||||
|
submit: 'Ask now',
|
||||||
|
idleStatus: 'Knowledge base connected. Waiting for a question.',
|
||||||
|
examples: 'Example questions',
|
||||||
|
workflow: 'Workflow',
|
||||||
|
workflow1: '1. Enable the AI switch in the admin and configure the chat model.',
|
||||||
|
workflow2: '2. Rebuild the index so Markdown content is chunked, embedded locally by the backend, and written into PostgreSQL pgvector.',
|
||||||
|
workflow3: '3. Each user question retrieves similar chunks from pgvector first, then the chat model answers with that context.',
|
||||||
|
emptyAnswer: 'No answer yet.',
|
||||||
|
requestFailed: 'Request failed: {message}',
|
||||||
|
streamUnsupported: 'This browser cannot read streaming responses.',
|
||||||
|
enterQuestion: 'Enter a question first.',
|
||||||
|
cacheRestored: 'Restored the answer from the current session cache.',
|
||||||
|
connecting: 'Opening stream connection...',
|
||||||
|
processing: 'Processing request...',
|
||||||
|
complete: 'Answer generated.',
|
||||||
|
streamFailed: 'Streaming request failed',
|
||||||
|
streamInterrupted: 'The streaming response ended early.',
|
||||||
|
retryLater: 'This request did not complete successfully. Please try again later.',
|
||||||
|
prefixedQuestion: 'The search query has been prefilled. Confirm manually to ask AI.',
|
||||||
|
sources: 'Sources',
|
||||||
|
},
|
||||||
|
about: {
|
||||||
|
pageTitle: 'About',
|
||||||
|
title: 'About',
|
||||||
|
intro: 'This page gathers the site owner profile, tech stack, system stats, and contact details while following the same language setting as the rest of the site.',
|
||||||
|
techStackCount: '{count} tech items',
|
||||||
|
profile: 'Profile',
|
||||||
|
contact: 'Contact',
|
||||||
|
website: 'Website',
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
pageTitle: 'Categories',
|
||||||
|
title: 'Categories',
|
||||||
|
intro: 'Browse posts by topic. This page now follows the same terminal language as the other list views.',
|
||||||
|
quickJump: 'Jump straight into category posts',
|
||||||
|
categoryPosts: 'Browse all posts and updates under {name}.',
|
||||||
|
empty: 'No category data yet',
|
||||||
|
},
|
||||||
|
friends: {
|
||||||
|
pageTitle: 'Links',
|
||||||
|
pageDescription: 'Exchange links with {siteName}',
|
||||||
|
title: 'Friend Links',
|
||||||
|
intro: 'This page gathers approved sites and keeps the application panel in the same visual language so the list and form feel like one screen.',
|
||||||
|
collection: 'Friend collection',
|
||||||
|
exchangeRules: 'Link exchange',
|
||||||
|
exchangeIntro: 'You are welcome to exchange links. Please make sure your site meets these conditions:',
|
||||||
|
rule1: 'Original content as the main focus',
|
||||||
|
rule2: 'Stable uptime',
|
||||||
|
rule3: 'No harmful content',
|
||||||
|
siteInfo: 'Site info:',
|
||||||
|
name: 'Name',
|
||||||
|
description: 'Description',
|
||||||
|
link: 'Link',
|
||||||
|
},
|
||||||
|
friendForm: {
|
||||||
|
title: 'Submit a link request',
|
||||||
|
intro: 'Fill in your site info and it will be sent to the moderation queue. Once approved, it will appear on the frontend automatically.',
|
||||||
|
reviewedOnline: 'Published after review',
|
||||||
|
siteName: 'Site name',
|
||||||
|
siteUrl: 'Site URL',
|
||||||
|
avatarUrl: 'Avatar URL',
|
||||||
|
category: 'Category',
|
||||||
|
description: 'Site description',
|
||||||
|
reciprocal: 'I already added this site to my links',
|
||||||
|
reciprocalHint: 'This is required before submission.',
|
||||||
|
copy: 'Copy',
|
||||||
|
copied: 'Copied',
|
||||||
|
submit: 'Submit request',
|
||||||
|
reset: 'Reset',
|
||||||
|
addReciprocalFirst: 'Please add this site to your links before submitting.',
|
||||||
|
submitting: 'Submitting link request...',
|
||||||
|
submitSuccess: 'Link request submitted. We will review it soon.',
|
||||||
|
submitFailed: 'Submit failed: {message}',
|
||||||
|
categoryTech: 'Tech',
|
||||||
|
categoryLife: 'Life',
|
||||||
|
categoryDesign: 'Design',
|
||||||
|
categoryOther: 'Other',
|
||||||
|
descriptionPlaceholder: 'Briefly describe your site...',
|
||||||
|
},
|
||||||
|
friendCard: {
|
||||||
|
externalLink: 'External link',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
pageTitle: 'Tags',
|
||||||
|
title: 'Tag Cloud',
|
||||||
|
intro: 'Browse posts through lightweight keyword slices. When a tag is selected, the result area keeps the same terminal card language.',
|
||||||
|
currentTag: 'Current: #{tag}',
|
||||||
|
selectedSummary: 'Tag #{tag} matched {count} posts',
|
||||||
|
browseTags: 'Browse tags',
|
||||||
|
emptyTags: 'No tag data yet',
|
||||||
|
emptyPosts: 'No posts found for this tag',
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
pageTitle: 'Timeline',
|
||||||
|
pageDescription: 'A timeline of {ownerName}\'s technical growth and life notes',
|
||||||
|
title: 'Timeline',
|
||||||
|
subtitle: '{count} entries · tracing {ownerName}\'s technical growth and life notes',
|
||||||
|
allYears: 'All',
|
||||||
|
},
|
||||||
|
reviews: {
|
||||||
|
pageTitle: 'Reviews',
|
||||||
|
pageDescription: 'Notes and ratings for games, music, anime, books, and films',
|
||||||
|
title: 'Reviews',
|
||||||
|
subtitle: 'Tracking thoughts on games, music, anime, books, and films',
|
||||||
|
total: 'Total reviews',
|
||||||
|
average: 'Average rating',
|
||||||
|
completed: 'Completed',
|
||||||
|
inProgress: 'In progress',
|
||||||
|
emptyData: 'No review data yet. Please check the backend API connection.',
|
||||||
|
emptyFiltered: 'No reviews match the current filter',
|
||||||
|
currentFilter: 'Current filter: {type}',
|
||||||
|
typeAll: 'All',
|
||||||
|
typeGame: 'Games',
|
||||||
|
typeAnime: 'Anime',
|
||||||
|
typeMusic: 'Music',
|
||||||
|
typeBook: 'Books',
|
||||||
|
typeMovie: 'Films',
|
||||||
|
},
|
||||||
|
notFound: {
|
||||||
|
pageTitle: 'Page not found',
|
||||||
|
pageDescription: 'The page you requested does not exist',
|
||||||
|
title: '404 - Page not found',
|
||||||
|
intro: 'The current request did not resolve to any content node. This page keeps the terminal-style error output, direct recovery actions, and real fallback articles.',
|
||||||
|
terminalLog: 'Terminal error log',
|
||||||
|
requestedRouteNotFound: 'error: requested route not found',
|
||||||
|
path: 'path',
|
||||||
|
time: 'time',
|
||||||
|
actions: 'Actions',
|
||||||
|
actionsIntro: 'Like a command palette, this page surfaces the most direct recovery paths first.',
|
||||||
|
searchHint: 'You can also use the search box in the header and grep through `articles/*.md` again.',
|
||||||
|
recommended: 'Recommended entries',
|
||||||
|
recommendedIntro: 'These use real article data so the 404 page does not send people into more dead ends.',
|
||||||
|
cannotLoad: 'Unable to load the article list right now.',
|
||||||
|
},
|
||||||
|
toc: {
|
||||||
|
title: 'Contents',
|
||||||
|
intro: 'Track document headings in real time and jump around like a terminal side panel.',
|
||||||
|
},
|
||||||
|
codeCopy: {
|
||||||
|
copy: 'Copy',
|
||||||
|
copied: 'Copied',
|
||||||
|
failed: 'Failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type MessageCatalog = typeof messages;
|
||||||
@@ -61,6 +61,9 @@ export interface SiteSettings {
|
|||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
techStack: string[];
|
techStack: string[];
|
||||||
|
ai: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SiteConfig {
|
export interface SiteConfig {
|
||||||
|
|||||||
@@ -92,3 +92,10 @@ export function filterPosts(
|
|||||||
export function getPostTypeColor(type: string): string {
|
export function getPostTypeColor(type: string): string {
|
||||||
return type === 'article' ? 'var(--primary)' : 'var(--secondary)';
|
return type === 'article' ? 'var(--primary)' : 'var(--secondary)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildParagraphDescriptors,
|
||||||
|
createParagraphExcerpt,
|
||||||
|
fnv1aHash,
|
||||||
|
normalizeParagraphText,
|
||||||
|
} from './paragraph-comments';
|
||||||
|
|||||||
57
frontend/src/lib/utils/paragraph-comments.ts
Normal file
57
frontend/src/lib/utils/paragraph-comments.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
export interface ParagraphDescriptor {
|
||||||
|
element: HTMLParagraphElement;
|
||||||
|
key: string;
|
||||||
|
excerpt: string;
|
||||||
|
normalizedText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeParagraphText(text: string): string {
|
||||||
|
return text.replace(/\s+/g, ' ').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createParagraphExcerpt(text: string, limit = 120): string {
|
||||||
|
const flattened = text.replace(/\s+/g, ' ').trim();
|
||||||
|
if (flattened.length <= limit) {
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${flattened.slice(0, limit).trimEnd()}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fnv1aHash(value: string): string {
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash ^= value.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (hash >>> 0).toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildParagraphDescriptors(container: HTMLElement): ParagraphDescriptor[] {
|
||||||
|
const occurrences = new Map<string, number>();
|
||||||
|
const paragraphs = Array.from(container.querySelectorAll('p')).filter(
|
||||||
|
child => child instanceof HTMLParagraphElement
|
||||||
|
) as HTMLParagraphElement[];
|
||||||
|
|
||||||
|
return paragraphs
|
||||||
|
.map(element => {
|
||||||
|
const normalizedText = normalizeParagraphText(element.textContent || '');
|
||||||
|
if (!normalizedText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = fnv1aHash(normalizedText);
|
||||||
|
const nextOccurrence = (occurrences.get(hash) || 0) + 1;
|
||||||
|
occurrences.set(hash, nextOccurrence);
|
||||||
|
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
key: `p-${hash}-${nextOccurrence}`,
|
||||||
|
excerpt: createParagraphExcerpt(element.textContent || ''),
|
||||||
|
normalizedText,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is ParagraphDescriptor => Boolean(item));
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ import Layout from '../layouts/BaseLayout.astro';
|
|||||||
import TerminalWindow from '../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../components/ui/TerminalWindow.astro';
|
||||||
import { terminalConfig } from '../lib/config/terminal';
|
import { terminalConfig } from '../lib/config/terminal';
|
||||||
import { api } from '../lib/api/client';
|
import { api } from '../lib/api/client';
|
||||||
|
import { getI18n } from '../lib/i18n';
|
||||||
|
|
||||||
const fullPrompt = `${terminalConfig.prompt.prefix}${terminalConfig.prompt.separator}${terminalConfig.prompt.path}${terminalConfig.prompt.suffix}`;
|
const fullPrompt = `${terminalConfig.prompt.prefix}${terminalConfig.prompt.separator}${terminalConfig.prompt.path}${terminalConfig.prompt.suffix}`;
|
||||||
|
const { locale, t } = getI18n(Astro);
|
||||||
|
|
||||||
let popularPosts: Awaited<ReturnType<typeof api.getPosts>> = [];
|
let popularPosts: Awaited<ReturnType<typeof api.getPosts>> = [];
|
||||||
|
|
||||||
@@ -15,7 +17,7 @@ try {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="404 - 页面未找到" description="您访问的页面不存在">
|
<Layout title={`404 - ${t('notFound.pageTitle')}`} description={t('notFound.pageDescription')}>
|
||||||
<div class="max-w-4xl mx-auto px-4 py-12">
|
<div class="max-w-4xl mx-auto px-4 py-12">
|
||||||
<TerminalWindow title={terminalConfig.title} class="w-full">
|
<TerminalWindow title={terminalConfig.title} class="w-full">
|
||||||
<div class="px-4 pb-2">
|
<div class="px-4 pb-2">
|
||||||
@@ -33,9 +35,9 @@ EEE RRRR RRRR O O RRRR
|
|||||||
E R R R R O O R R
|
E R R R R O O R R
|
||||||
EEEEE R R R R OOO R R</pre>
|
EEEEE R R R R OOO R R</pre>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">404 - 页面未找到</h1>
|
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">404 - {t('notFound.pageTitle')}</h1>
|
||||||
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
当前请求没有命中任何内容节点。下面保留了终端化错误信息、可执行操作,以及可回退到的真实文章入口。
|
{t('notFound.intro')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,10 +49,10 @@ EEEEE R R R R OOO R R</pre>
|
|||||||
<span class="ml-2 text-[var(--secondary)]">find ./ -name "*.html"</span>
|
<span class="ml-2 text-[var(--secondary)]">find ./ -name "*.html"</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border px-4 py-4 text-sm" style="border-color: color-mix(in oklab, var(--danger) 24%, var(--border-color)); background: color-mix(in oklab, var(--danger) 10%, var(--header-bg));">
|
<div class="rounded-2xl border px-4 py-4 text-sm" style="border-color: color-mix(in oklab, var(--danger) 24%, var(--border-color)); background: color-mix(in oklab, var(--danger) 10%, var(--header-bg));">
|
||||||
<div class="text-[var(--text-secondary)]">terminal_error.log</div>
|
<div class="text-[var(--text-secondary)]">{t('notFound.terminalLog')}</div>
|
||||||
<div class="mt-2 text-[var(--danger)]">error: requested route not found</div>
|
<div class="mt-2 text-[var(--danger)]">{t('notFound.requestedRouteNotFound')}</div>
|
||||||
<div class="mt-2 text-[var(--text-secondary)]">path: <span id="current-path" class="font-mono text-[var(--title-color)]"></span></div>
|
<div class="mt-2 text-[var(--text-secondary)]">{t('notFound.path')}: <span id="current-path" class="font-mono text-[var(--title-color)]"></span></div>
|
||||||
<div class="text-[var(--text-secondary)]">time: <span id="current-time" class="font-mono text-[var(--title-color)]"></span></div>
|
<div class="text-[var(--text-secondary)]">{t('notFound.time')}: <span id="current-time" class="font-mono text-[var(--title-color)]"></span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,29 +64,29 @@ EEEEE R R R R OOO R R</pre>
|
|||||||
<i class="fas fa-wrench"></i>
|
<i class="fas fa-wrench"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">可执行操作</h2>
|
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('notFound.actions')}</h2>
|
||||||
<p class="text-sm text-[var(--text-secondary)]">像命令面板一样,优先给出直接可走的恢复路径。</p>
|
<p class="text-sm text-[var(--text-secondary)]">{t('notFound.actionsIntro')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<button onclick="history.back()" class="terminal-action-button">
|
<button onclick="history.back()" class="terminal-action-button">
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span>go back</span>
|
<span>{t('common.goBack')}</span>
|
||||||
</button>
|
</button>
|
||||||
<a href="/" class="terminal-action-button terminal-action-button-primary">
|
<a href="/" class="terminal-action-button terminal-action-button-primary">
|
||||||
<i class="fas fa-house"></i>
|
<i class="fas fa-house"></i>
|
||||||
<span>home</span>
|
<span>{t('common.home')}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/articles" class="terminal-action-button">
|
<a href="/articles" class="terminal-action-button">
|
||||||
<i class="fas fa-file-lines"></i>
|
<i class="fas fa-file-lines"></i>
|
||||||
<span>browse posts</span>
|
<span>{t('common.browsePosts')}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="terminal-empty py-8">
|
<div class="terminal-empty py-8">
|
||||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
也可以直接使用顶部的搜索输入框,在 `articles/*.md` 里重新 grep 一次相关关键字。
|
{t('notFound.searchHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -95,8 +97,8 @@ EEEEE R R R R OOO R R</pre>
|
|||||||
<i class="fas fa-book-open"></i>
|
<i class="fas fa-book-open"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">推荐入口</h2>
|
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('notFound.recommended')}</h2>
|
||||||
<p class="text-sm text-[var(--text-secondary)]">使用真实文章数据,避免 404 页面再把人带进不存在的地址。</p>
|
<p class="text-sm text-[var(--text-secondary)]">{t('notFound.recommendedIntro')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -113,7 +115,7 @@ EEEEE R R R R OOO R R</pre>
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div class="terminal-empty py-8">
|
<div class="terminal-empty py-8">
|
||||||
<p class="text-sm text-[var(--text-secondary)]">暂时无法读取文章列表。</p>
|
<p class="text-sm text-[var(--text-secondary)]">{t('notFound.cannotLoad')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -124,9 +126,9 @@ EEEEE R R R R OOO R R</pre>
|
|||||||
</TerminalWindow>
|
</TerminalWindow>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline define:vars={{ locale }}>
|
||||||
document.getElementById('current-path').textContent = window.location.pathname;
|
document.getElementById('current-path').textContent = window.location.pathname;
|
||||||
document.getElementById('current-time').textContent = new Date().toLocaleString('zh-CN', {
|
document.getElementById('current-time').textContent = new Date().toLocaleString(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import StatsList from '../../components/StatsList.astro';
|
|||||||
import TechStackList from '../../components/TechStackList.astro';
|
import TechStackList from '../../components/TechStackList.astro';
|
||||||
import InfoTile from '../../components/ui/InfoTile.astro';
|
import InfoTile from '../../components/ui/InfoTile.astro';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
let systemStats = [];
|
let systemStats = [];
|
||||||
let techStack = [];
|
let techStack = [];
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [settings, posts, tags, friendLinks] = await Promise.all([
|
const [settings, posts, tags, friendLinks] = await Promise.all([
|
||||||
@@ -22,26 +26,26 @@ try {
|
|||||||
siteSettings = settings;
|
siteSettings = settings;
|
||||||
techStack = siteSettings.techStack.map(name => ({ name }));
|
techStack = siteSettings.techStack.map(name => ({ name }));
|
||||||
systemStats = [
|
systemStats = [
|
||||||
{ label: 'Posts', value: String(posts.length) },
|
{ label: t('common.posts'), value: String(posts.length) },
|
||||||
{ label: 'Tags', value: String(tags.length) },
|
{ label: t('common.tags'), value: String(tags.length) },
|
||||||
{ label: 'Friends', value: String(friendLinks.filter(friend => friend.status === 'approved').length) },
|
{ label: t('common.friends'), value: String(friendLinks.filter(friend => friend.status === 'approved').length) },
|
||||||
{ label: 'Location', value: siteSettings.location || 'Unknown' },
|
{ label: t('common.location'), value: siteSettings.location || t('common.unknown') },
|
||||||
];
|
];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load about data:', error);
|
console.error('Failed to load about data:', error);
|
||||||
techStack = siteSettings.techStack.map(name => ({ name }));
|
techStack = siteSettings.techStack.map(name => ({ name }));
|
||||||
systemStats = [
|
systemStats = [
|
||||||
{ label: 'Posts', value: '0' },
|
{ label: t('common.posts'), value: '0' },
|
||||||
{ label: 'Tags', value: '0' },
|
{ label: t('common.tags'), value: '0' },
|
||||||
{ label: 'Friends', value: '0' },
|
{ label: t('common.friends'), value: '0' },
|
||||||
{ label: 'Location', value: siteSettings.location || 'Unknown' },
|
{ label: t('common.location'), value: siteSettings.location || t('common.unknown') },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
|
const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`关于 - ${siteSettings.siteShortName}`} description={siteSettings.siteDescription}>
|
<BaseLayout title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`} description={siteSettings.siteDescription}>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/about" class="w-full">
|
<TerminalWindow title="~/about" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
@@ -53,20 +57,20 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
|
|||||||
<i class="fas fa-user-circle"></i>
|
<i class="fas fa-user-circle"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-[var(--title-color)]">关于我</h1>
|
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('about.title')}</h1>
|
||||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
|
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
这里汇总站点主人、技术栈、系统状态和联系方式,现在整体语言会更接近首页与评价页。
|
{t('about.intro')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex flex-wrap gap-2">
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-location-dot text-[var(--primary)]"></i>
|
<i class="fas fa-location-dot text-[var(--primary)]"></i>
|
||||||
<span>{siteSettings.location || 'Unknown'}</span>
|
<span>{siteSettings.location || t('common.unknown')}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-layer-group text-[var(--primary)]"></i>
|
<i class="fas fa-layer-group text-[var(--primary)]"></i>
|
||||||
<span>{techStack.length} 项技术栈</span>
|
<span>{t('about.techStackCount', { count: techStack.length })}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +152,7 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
|
|||||||
layout="grid"
|
layout="grid"
|
||||||
>
|
>
|
||||||
<i class="fas fa-envelope text-[var(--text-secondary)]"></i>
|
<i class="fas fa-envelope text-[var(--text-secondary)]"></i>
|
||||||
<span class="text-sm">Email</span>
|
<span class="text-sm">{t('comments.email')}</span>
|
||||||
</InfoTile>
|
</InfoTile>
|
||||||
)}
|
)}
|
||||||
<InfoTile
|
<InfoTile
|
||||||
@@ -159,7 +163,7 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
<i class="fas fa-globe text-[var(--text-secondary)]"></i>
|
<i class="fas fa-globe text-[var(--text-secondary)]"></i>
|
||||||
<span class="text-sm">Website</span>
|
<span class="text-sm">{t('about.website')}</span>
|
||||||
</InfoTile>
|
</InfoTile>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import BackToTop from '../../components/BackToTop.astro';
|
|||||||
import Lightbox from '../../components/Lightbox.astro';
|
import Lightbox from '../../components/Lightbox.astro';
|
||||||
import CodeCopyButton from '../../components/CodeCopyButton.astro';
|
import CodeCopyButton from '../../components/CodeCopyButton.astro';
|
||||||
import Comments from '../../components/Comments.astro';
|
import Comments from '../../components/Comments.astro';
|
||||||
|
import ParagraphComments from '../../components/ParagraphComments.astro';
|
||||||
import { apiClient } from '../../lib/api/client';
|
import { apiClient } from '../../lib/api/client';
|
||||||
|
import { formatReadTime, getI18n } from '../../lib/i18n';
|
||||||
import { resolveFileRef, getPostTypeColor } from '../../lib/utils';
|
import { resolveFileRef, getPostTypeColor } from '../../lib/utils';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -33,6 +35,7 @@ const typeColor = getPostTypeColor(post.type || 'article');
|
|||||||
const contentText = post.content || post.description || '';
|
const contentText = post.content || post.description || '';
|
||||||
const wordCount = contentText.length;
|
const wordCount = contentText.length;
|
||||||
const readTimeMinutes = Math.ceil(wordCount / 300);
|
const readTimeMinutes = Math.ceil(wordCount / 300);
|
||||||
|
const { locale, t } = getI18n(Astro);
|
||||||
const articleMarkdown = contentText.replace(/^#\s+.+\r?\n+/, '');
|
const articleMarkdown = contentText.replace(/^#\s+.+\r?\n+/, '');
|
||||||
|
|
||||||
const markdownProcessor = await createMarkdownProcessor();
|
const markdownProcessor = await createMarkdownProcessor();
|
||||||
@@ -45,7 +48,7 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
|||||||
<Lightbox />
|
<Lightbox />
|
||||||
<CodeCopyButton />
|
<CodeCopyButton />
|
||||||
|
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8" data-article-slug={post.slug}>
|
||||||
<div class="flex flex-col gap-8 lg:flex-row">
|
<div class="flex flex-col gap-8 lg:flex-row">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<TerminalWindow title={`~/content/posts/${post.slug}.md`} class="w-full">
|
<TerminalWindow title={`~/content/posts/${post.slug}.md`} class="w-full">
|
||||||
@@ -55,17 +58,17 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<a href="/articles" class="terminal-link-arrow">
|
<a href="/articles" class="terminal-link-arrow">
|
||||||
<i class="fas fa-arrow-left"></i>
|
<i class="fas fa-arrow-left"></i>
|
||||||
<span>返回文章索引</span>
|
<span>{t('article.backToArticles')}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="terminal-kicker">
|
<span class="terminal-kicker">
|
||||||
<i class="fas fa-file-code"></i>
|
<i class="fas fa-file-code"></i>
|
||||||
document session
|
{t('article.documentSession')}
|
||||||
</span>
|
</span>
|
||||||
<span class="terminal-chip">
|
<span class="terminal-chip">
|
||||||
<span class="h-2.5 w-2.5 rounded-full" style={`background-color: ${typeColor}`}></span>
|
<span class="h-2.5 w-2.5 rounded-full" style={`background-color: ${typeColor}`}></span>
|
||||||
{post.type === 'article' ? 'article' : 'tweet'}
|
{post.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||||
</span>
|
</span>
|
||||||
<span class="terminal-chip">
|
<span class="terminal-chip">
|
||||||
<i class="fas fa-folder-tree text-[var(--primary)]"></i>
|
<i class="fas fa-folder-tree text-[var(--primary)]"></i>
|
||||||
@@ -81,11 +84,11 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
|||||||
</span>
|
</span>
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="far fa-clock text-[var(--primary)]"></i>
|
<i class="far fa-clock text-[var(--primary)]"></i>
|
||||||
{readTimeMinutes} min
|
{formatReadTime(locale, readTimeMinutes, t)}
|
||||||
</span>
|
</span>
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-font text-[var(--primary)]"></i>
|
<i class="fas fa-font text-[var(--primary)]"></i>
|
||||||
{wordCount} chars
|
{t('common.characters', { count: wordCount })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,6 +129,12 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-4 pb-2">
|
||||||
|
<div class="ml-4 mt-4">
|
||||||
|
<ParagraphComments postSlug={post.slug} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="px-4 py-6">
|
<div class="px-4 py-6">
|
||||||
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span class="text-sm text-[var(--text-secondary)]">
|
<span class="text-sm text-[var(--text-secondary)]">
|
||||||
@@ -134,14 +143,14 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
|
|||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<a href="/articles" class="terminal-action-button">
|
<a href="/articles" class="terminal-action-button">
|
||||||
<i class="fas fa-list"></i>
|
<i class="fas fa-list"></i>
|
||||||
<span>back to index</span>
|
<span>{t('common.backToIndex')}</span>
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
class="terminal-action-button terminal-action-button-primary"
|
class="terminal-action-button terminal-action-button-primary"
|
||||||
onclick={`navigator.clipboard.writeText(window.location.href)`}
|
onclick={`navigator.clipboard.writeText(window.location.href)`}
|
||||||
>
|
>
|
||||||
<i class="fas fa-link"></i>
|
<i class="fas fa-link"></i>
|
||||||
<span>copy permalink</span>
|
<span>{t('common.copyPermalink')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
|||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||||
import PostCard from '../../components/PostCard.astro';
|
import PostCard from '../../components/PostCard.astro';
|
||||||
import { terminalConfig } from '../../lib/config/terminal';
|
|
||||||
import { api } from '../../lib/api/client';
|
import { api } from '../../lib/api/client';
|
||||||
|
import { getI18n } from '../../lib/i18n';
|
||||||
import type { Category, Post, Tag } from '../../lib/types';
|
import type { Category, Post, Tag } from '../../lib/types';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -15,6 +15,7 @@ let allTags: Tag[] = [];
|
|||||||
let allCategories: Category[] = [];
|
let allCategories: Category[] = [];
|
||||||
const url = new URL(Astro.request.url);
|
const url = new URL(Astro.request.url);
|
||||||
const selectedSearch = url.searchParams.get('search') || '';
|
const selectedSearch = url.searchParams.get('search') || '';
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
allPosts = selectedSearch ? await api.searchPosts(selectedSearch) : await api.getPosts();
|
allPosts = selectedSearch ? await api.searchPosts(selectedSearch) : await api.getPosts();
|
||||||
@@ -54,9 +55,9 @@ const startIndex = (currentPage - 1) * postsPerPage;
|
|||||||
const paginatedPosts = filteredPosts.slice(startIndex, startIndex + postsPerPage);
|
const paginatedPosts = filteredPosts.slice(startIndex, startIndex + postsPerPage);
|
||||||
|
|
||||||
const postTypeFilters = [
|
const postTypeFilters = [
|
||||||
{ id: 'all', name: '全部', icon: 'fa-stream' },
|
{ id: 'all', name: t('common.all'), icon: 'fa-stream' },
|
||||||
{ id: 'article', name: terminalConfig.postTypes.article.label, icon: 'fa-file-alt' },
|
{ id: 'article', name: t('common.article'), icon: 'fa-file-alt' },
|
||||||
{ id: 'tweet', name: terminalConfig.postTypes.tweet.label, icon: 'fa-comment-dots' }
|
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const typePromptCommand = `./filter --type ${selectedType || 'all'}`;
|
const typePromptCommand = `./filter --type ${selectedType || 'all'}`;
|
||||||
@@ -89,21 +90,21 @@ const buildArticlesUrl = ({
|
|||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="文章列表 - Termi">
|
<BaseLayout title={`${t('articlesPage.title')} - Termi`}>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/articles/index" class="w-full">
|
<TerminalWindow title="~/articles/index" class="w-full">
|
||||||
<div class="px-4 pb-2">
|
<div class="px-4 pb-2">
|
||||||
<CommandPrompt command="fd . ./content/posts --full-path" />
|
<CommandPrompt command="fd . ./content/posts --full-path" />
|
||||||
|
|
||||||
<div class="ml-4 mt-4 space-y-3">
|
<div class="ml-4 mt-4 space-y-3">
|
||||||
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">文章索引</h1>
|
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{t('articlesPage.title')}</h1>
|
||||||
<p class="max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
<p class="max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
按类型、分类和标签筛选内容。这里保留更轻的 prompt 标题结构,下方筛选拆成独立区域。
|
{t('articlesPage.description')}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-file-lines text-[var(--primary)]"></i>
|
<i class="fas fa-file-lines text-[var(--primary)]"></i>
|
||||||
共 {filteredPosts.length} 篇
|
{t('articlesPage.totalPosts', { count: filteredPosts.length })}
|
||||||
</span>
|
</span>
|
||||||
{selectedSearch && (
|
{selectedSearch && (
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
@@ -154,7 +155,7 @@ const buildArticlesUrl = ({
|
|||||||
active={!selectedCategory}
|
active={!selectedCategory}
|
||||||
>
|
>
|
||||||
<i class="fas fa-folder-tree"></i>
|
<i class="fas fa-folder-tree"></i>
|
||||||
<span class="font-medium">全部分类</span>
|
<span class="font-medium">{t('articlesPage.allCategories')}</span>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
{allCategories.map(category => (
|
{allCategories.map(category => (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
@@ -181,7 +182,7 @@ const buildArticlesUrl = ({
|
|||||||
active={!selectedTag}
|
active={!selectedTag}
|
||||||
>
|
>
|
||||||
<i class="fas fa-hashtag"></i>
|
<i class="fas fa-hashtag"></i>
|
||||||
<span class="font-medium">全部标签</span>
|
<span class="font-medium">{t('articlesPage.allTags')}</span>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
{allTags.map(tag => (
|
{allTags.map(tag => (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
@@ -211,13 +212,13 @@ const buildArticlesUrl = ({
|
|||||||
<span class="terminal-section-icon">
|
<span class="terminal-section-icon">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
</span>
|
</span>
|
||||||
<h2 class="text-xl font-semibold text-[var(--title-color)]">没有匹配结果</h2>
|
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</h2>
|
||||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
当前筛选条件下没有找到文章。可以清空标签或关键字,重新浏览整个内容目录。
|
{t('articlesPage.emptyDescription')}
|
||||||
</p>
|
</p>
|
||||||
<a href="/articles" class="terminal-action-button terminal-action-button-primary">
|
<a href="/articles" class="terminal-action-button terminal-action-button-primary">
|
||||||
<i class="fas fa-rotate-left"></i>
|
<i class="fas fa-rotate-left"></i>
|
||||||
<span>reset filters</span>
|
<span>{t('common.resetFilters')}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +229,7 @@ const buildArticlesUrl = ({
|
|||||||
<div class="px-4 py-6">
|
<div class="px-4 py-6">
|
||||||
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<span class="text-sm text-[var(--text-secondary)]">
|
<span class="text-sm text-[var(--text-secondary)]">
|
||||||
page {currentPage}/{totalPages} · {totalPosts} results
|
{t('articlesPage.pageSummary', { current: currentPage, total: totalPages, count: totalPosts })}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{currentPage > 1 && (
|
{currentPage > 1 && (
|
||||||
@@ -237,7 +238,7 @@ const buildArticlesUrl = ({
|
|||||||
class="terminal-action-button"
|
class="terminal-action-button"
|
||||||
>
|
>
|
||||||
<i class="fas fa-chevron-left"></i>
|
<i class="fas fa-chevron-left"></i>
|
||||||
<span>prev</span>
|
<span>{t('articlesPage.previous')}</span>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{currentPage < totalPages && (
|
{currentPage < totalPages && (
|
||||||
@@ -245,7 +246,7 @@ const buildArticlesUrl = ({
|
|||||||
href={buildArticlesUrl({ page: currentPage + 1 })}
|
href={buildArticlesUrl({ page: currentPage + 1 })}
|
||||||
class="terminal-action-button terminal-action-button-primary"
|
class="terminal-action-button terminal-action-button-primary"
|
||||||
>
|
>
|
||||||
<span>next</span>
|
<span>{t('articlesPage.next')}</span>
|
||||||
<i class="fas fa-chevron-right"></i>
|
<i class="fas fa-chevron-right"></i>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
442
frontend/src/pages/ask/index.astro
Normal file
442
frontend/src/pages/ask/index.astro
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
|
const { locale, t } = getI18n(Astro);
|
||||||
|
|
||||||
|
try {
|
||||||
|
siteSettings = await api.getSiteSettings();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load site settings:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiEnabled = siteSettings.ai.enabled;
|
||||||
|
const sampleQuestions = [
|
||||||
|
locale === 'en'
|
||||||
|
? 'What kind of topics does this blog mainly cover?'
|
||||||
|
: '这个博客主要写什么内容?',
|
||||||
|
locale === 'en'
|
||||||
|
? 'What recent posts are related to Astro or frontend topics?'
|
||||||
|
: '最近有哪些和 Astro 或前端相关的文章?',
|
||||||
|
locale === 'en'
|
||||||
|
? 'What is the site owner\'s tech stack and personal profile?'
|
||||||
|
: '站长的技术栈和个人介绍是什么?'
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`} description={t('ask.pageDescription', { siteName: siteSettings.siteName })}>
|
||||||
|
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs uppercase tracking-[0.26em] text-[var(--text-tertiary)]">knowledge terminal</div>
|
||||||
|
<h1 class="mt-2 text-2xl font-bold text-[var(--title-color)]">{t('ask.title')}</h1>
|
||||||
|
<p class="mt-2 text-sm text-[var(--text-secondary)]">{t('ask.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div class:list={[
|
||||||
|
'rounded-full border px-3 py-1 text-xs font-mono',
|
||||||
|
aiEnabled
|
||||||
|
? 'border-emerald-500/35 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
|
||||||
|
: 'border-amber-500/35 bg-amber-500/10 text-amber-600 dark:text-amber-300'
|
||||||
|
]}>
|
||||||
|
{aiEnabled ? t('common.featureOn') : t('common.featureOff')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-8 px-5 py-6 lg:grid-cols-[minmax(0,1.5fr)_18rem]">
|
||||||
|
<div class="min-w-0">
|
||||||
|
{aiEnabled ? (
|
||||||
|
<>
|
||||||
|
<form id="ai-form" class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||||
|
<label class="mb-3 block text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">user@blog:~/ask$ ./answer</label>
|
||||||
|
<textarea
|
||||||
|
id="ai-question"
|
||||||
|
class="min-h-[140px] w-full resize-y rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 font-mono text-sm text-[var(--text)] outline-none transition focus:border-[var(--primary)]"
|
||||||
|
placeholder={t('ask.textareaPlaceholder')}
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||||
|
<button type="submit" id="ai-submit" class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/35 bg-[var(--primary)]/10 px-4 py-2 text-sm font-medium text-[var(--primary)] transition hover:bg-[var(--primary)]/16">
|
||||||
|
<i class="fas fa-terminal text-xs"></i>
|
||||||
|
<span>{t('ask.submit')}</span>
|
||||||
|
</button>
|
||||||
|
<span id="ai-status" class="text-sm text-[var(--text-secondary)]">{t('ask.idleStatus')}</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="ai-result" class="mt-6 hidden rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/65 p-5">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">assistant@blog</div>
|
||||||
|
<div id="ai-meta" class="text-xs text-[var(--text-tertiary)]"></div>
|
||||||
|
</div>
|
||||||
|
<div id="ai-answer" class="terminal-document mt-4"></div>
|
||||||
|
<div id="ai-sources" class="mt-5 grid gap-3"></div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div class="rounded-2xl border border-dashed border-[var(--border-color)] bg-[var(--bg)]/55 px-5 py-8">
|
||||||
|
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">feature disabled</div>
|
||||||
|
<h2 class="mt-3 text-xl font-semibold text-[var(--title-color)]">{t('ask.disabledTitle')}</h2>
|
||||||
|
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
|
{t('ask.disabledDescription')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="space-y-4">
|
||||||
|
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
|
||||||
|
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.examples')}</div>
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
{sampleQuestions.map((question) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="sample-question w-full rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-3 py-2 text-left text-sm text-[var(--text-secondary)] transition hover:border-[var(--primary)]/30 hover:text-[var(--text)]"
|
||||||
|
data-question={question}
|
||||||
|
>
|
||||||
|
{question}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
|
||||||
|
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.workflow')}</div>
|
||||||
|
<ol class="mt-4 space-y-2 text-sm leading-7 text-[var(--text-secondary)]">
|
||||||
|
<li>{t('ask.workflow1')}</li>
|
||||||
|
<li>{t('ask.workflow2')}</li>
|
||||||
|
<li>{t('ask.workflow3')}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
{aiEnabled && (
|
||||||
|
<script is:inline define:vars={{ apiBase: 'http://localhost:5150/api' }}>
|
||||||
|
const t = window.__termiTranslate;
|
||||||
|
const form = document.getElementById('ai-form');
|
||||||
|
const input = document.getElementById('ai-question');
|
||||||
|
const submit = document.getElementById('ai-submit');
|
||||||
|
const status = document.getElementById('ai-status');
|
||||||
|
const result = document.getElementById('ai-result');
|
||||||
|
const answer = document.getElementById('ai-answer');
|
||||||
|
const sources = document.getElementById('ai-sources');
|
||||||
|
const meta = document.getElementById('ai-meta');
|
||||||
|
const sampleButtons = Array.from(document.querySelectorAll('.sample-question'));
|
||||||
|
const answerCache = new Map();
|
||||||
|
const prefilledQuestion = new URLSearchParams(window.location.search).get('q')?.trim() || '';
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInlineMarkdown(value) {
|
||||||
|
let rendered = escapeHtml(value || '');
|
||||||
|
|
||||||
|
rendered = rendered.replace(
|
||||||
|
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||||
|
'<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>'
|
||||||
|
);
|
||||||
|
rendered = rendered.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
rendered = rendered.replace(/(^|[\s(])\*([^*]+)\*(?=[\s).,!?;:]|$)/g, '$1<em>$2</em>');
|
||||||
|
|
||||||
|
return rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(value) {
|
||||||
|
const normalized = String(value || '').replace(/\r\n/g, '\n').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return `<p>${escapeHtml(t('ask.emptyAnswer'))}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeBlocks = [];
|
||||||
|
const withCodePlaceholders = normalized.replace(/```([\w-]*)\n([\s\S]*?)```/g, (_, language, code) => {
|
||||||
|
const token = `@@CODEBLOCK_${codeBlocks.length}@@`;
|
||||||
|
codeBlocks.push(
|
||||||
|
`<pre><code class="language-${escapeHtml(language || 'text')}">${escapeHtml(code.trim())}</code></pre>`
|
||||||
|
);
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
|
||||||
|
const blocks = withCodePlaceholders
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((block) => block.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const html = blocks.map((block) => {
|
||||||
|
const codeMatch = block.match(/^@@CODEBLOCK_(\d+)@@$/);
|
||||||
|
if (codeMatch) {
|
||||||
|
return codeBlocks[Number(codeMatch[1])] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const headingMatch = block.match(/^(#{1,4})\s+(.+)$/);
|
||||||
|
if (headingMatch) {
|
||||||
|
const level = Math.min(headingMatch[1].length, 4);
|
||||||
|
return `<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = block.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
if (lines.length && lines.every((line) => /^[-*]\s+/.test(line))) {
|
||||||
|
return `<ul>${lines.map((line) => `<li>${renderInlineMarkdown(line.replace(/^[-*]\s+/, ''))}</li>`).join('')}</ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length && lines.every((line) => /^\d+\.\s+/.test(line))) {
|
||||||
|
return `<ol>${lines.map((line) => `<li>${renderInlineMarkdown(line.replace(/^\d+\.\s+/, ''))}</li>`).join('')}</ol>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length && lines.every((line) => /^>\s?/.test(line))) {
|
||||||
|
return `<blockquote>${lines.map((line) => renderInlineMarkdown(line.replace(/^>\s?/, ''))).join('<br />')}</blockquote>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<p>${lines.map((line) => renderInlineMarkdown(line)).join('<br />')}</p>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return html.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInteractiveState(isLoading) {
|
||||||
|
if (submit) {
|
||||||
|
submit.toggleAttribute('disabled', isLoading);
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleButtons.forEach((button) => {
|
||||||
|
button.toggleAttribute('disabled', isLoading);
|
||||||
|
button.classList.toggle('opacity-60', isLoading);
|
||||||
|
button.classList.toggle('cursor-not-allowed', isLoading);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSources(items) {
|
||||||
|
if (!Array.isArray(items) || !items.length) {
|
||||||
|
sources.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sources.innerHTML = `
|
||||||
|
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">${escapeHtml(t('ask.sources'))}</div>
|
||||||
|
${items.map((item) => `
|
||||||
|
<a href="/articles/${encodeURIComponent(item.slug)}" class="block rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 transition hover:border-[var(--primary)]/35">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="text-sm font-semibold text-[var(--title-color)]">${escapeHtml(item.title)}</div>
|
||||||
|
<div class="text-[11px] font-mono text-[var(--text-tertiary)]">score ${escapeHtml(item.score)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">${escapeHtml(item.excerpt)}</div>
|
||||||
|
</a>
|
||||||
|
`).join('')}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorMessage(response) {
|
||||||
|
const rawText = await response.text().catch(() => '');
|
||||||
|
if (!rawText) {
|
||||||
|
return t('ask.requestFailed', { message: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawText);
|
||||||
|
return parsed.description || parsed.error || rawText;
|
||||||
|
} catch {
|
||||||
|
return rawText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResult(data) {
|
||||||
|
result.classList.remove('hidden');
|
||||||
|
answer.innerHTML = renderMarkdown(data.answer);
|
||||||
|
meta.textContent = `chunks ${data.indexed_chunks}${data.last_indexed_at ? ` · indexed ${data.last_indexed_at}` : ''}`;
|
||||||
|
renderSources(data.sources || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeNextSseEvent(buffer) {
|
||||||
|
const lfBoundary = buffer.indexOf('\n\n');
|
||||||
|
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||||
|
|
||||||
|
let boundaryIndex = -1;
|
||||||
|
let separatorLength = 0;
|
||||||
|
|
||||||
|
if (lfBoundary !== -1) {
|
||||||
|
boundaryIndex = lfBoundary;
|
||||||
|
separatorLength = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (crlfBoundary !== -1 && (boundaryIndex === -1 || crlfBoundary < boundaryIndex)) {
|
||||||
|
boundaryIndex = crlfBoundary;
|
||||||
|
separatorLength = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boundaryIndex === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawEvent = buffer.slice(0, boundaryIndex);
|
||||||
|
const rest = buffer.slice(boundaryIndex + separatorLength);
|
||||||
|
const normalized = rawEvent.replace(/\r\n/g, '\n');
|
||||||
|
const lines = normalized.split('\n');
|
||||||
|
let event = 'message';
|
||||||
|
const dataLines = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
event = line.slice(6).trim() || 'message';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).trimStart());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
data: dataLines.join('\n'),
|
||||||
|
rest
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readStreamResponse(response, handlers) {
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error(t('ask.streamUnsupported'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const parsed = takeNextSseEvent(buffer);
|
||||||
|
if (!parsed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer = parsed.rest;
|
||||||
|
|
||||||
|
if (!parsed.data || parsed.data === '[DONE]') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(parsed.data);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers[parsed.event]?.(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ask(question) {
|
||||||
|
const trimmed = String(question || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
status.textContent = t('ask.enterQuestion');
|
||||||
|
input?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = answerCache.get(trimmed);
|
||||||
|
if (cached) {
|
||||||
|
renderResult(cached);
|
||||||
|
status.textContent = t('ask.cacheRestored');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInteractiveState(true);
|
||||||
|
result.classList.remove('hidden');
|
||||||
|
answer.innerHTML = `<p>${escapeHtml(t('ask.connecting'))}</p>`;
|
||||||
|
sources.innerHTML = '';
|
||||||
|
meta.textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiBase}/ai/ask/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ question: trimmed })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
let streamedAnswer = '';
|
||||||
|
let completed = false;
|
||||||
|
|
||||||
|
await readStreamResponse(response, {
|
||||||
|
status: () => {
|
||||||
|
status.textContent = t('ask.processing');
|
||||||
|
},
|
||||||
|
delta: (payload) => {
|
||||||
|
streamedAnswer += payload.delta || '';
|
||||||
|
answer.innerHTML = renderMarkdown(streamedAnswer);
|
||||||
|
},
|
||||||
|
complete: (payload) => {
|
||||||
|
completed = true;
|
||||||
|
answerCache.set(trimmed, payload);
|
||||||
|
renderResult(payload);
|
||||||
|
status.textContent = t('ask.complete');
|
||||||
|
},
|
||||||
|
error: (payload) => {
|
||||||
|
throw new Error(payload.message || t('ask.streamFailed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!completed) {
|
||||||
|
throw new Error(t('ask.streamInterrupted'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
result.classList.remove('hidden');
|
||||||
|
answer.innerHTML = `<p>${escapeHtml(t('ask.requestFailed', { message: error?.message || 'unknown error' }))}</p>`;
|
||||||
|
meta.textContent = '';
|
||||||
|
sources.innerHTML = '';
|
||||||
|
status.textContent = t('ask.retryLater');
|
||||||
|
} finally {
|
||||||
|
setInteractiveState(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form?.addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
ask(input?.value || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.sample-question').forEach((button) => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const question = button.getAttribute('data-question') || '';
|
||||||
|
if (input) {
|
||||||
|
input.value = question;
|
||||||
|
}
|
||||||
|
ask(question);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prefilledQuestion) {
|
||||||
|
if (input) {
|
||||||
|
input.value = prefilledQuestion;
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
status.textContent = t('ask.prefixedQuestion');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
)}
|
||||||
@@ -3,8 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
|
|||||||
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import { api } from '../../lib/api/client';
|
import { api } from '../../lib/api/client';
|
||||||
|
import { getI18n } from '../../lib/i18n';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
|
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
categories = await api.getCategories();
|
categories = await api.getCategories();
|
||||||
@@ -13,7 +17,7 @@ try {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="分类 - Termi">
|
<BaseLayout title={`${t('categories.pageTitle')} - Termi`}>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/categories" class="w-full">
|
<TerminalWindow title="~/categories" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
@@ -25,20 +29,20 @@ try {
|
|||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-[var(--title-color)]">文章分类</h1>
|
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('categories.title')}</h1>
|
||||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
|
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
按内容主题浏览文章,分类页现在和其他列表页保持同一套终端面板语言。
|
{t('categories.intro')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex flex-wrap gap-2">
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-layer-group text-[var(--primary)]"></i>
|
<i class="fas fa-layer-group text-[var(--primary)]"></i>
|
||||||
<span>{categories.length} 个分类</span>
|
<span>{t('common.categoriesCount', { count: categories.length })}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-terminal text-[var(--primary)]"></i>
|
<i class="fas fa-terminal text-[var(--primary)]"></i>
|
||||||
<span>快速跳转分类文章</span>
|
<span>{t('categories.quickJump')}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,14 +71,14 @@ try {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span class="terminal-chip text-xs py-1 px-2.5">
|
<span class="terminal-chip text-xs py-1 px-2.5">
|
||||||
<span>{category.count} 篇</span>
|
<span>{t('common.postsCount', { count: category.count })}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">
|
<p class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
浏览 {category.name} 主题下的全部文章和更新记录。
|
{t('categories.categoryPosts', { name: category.name })}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4 terminal-link-arrow">
|
<div class="mt-4 terminal-link-arrow">
|
||||||
<span>查看分类文章</span>
|
<span>{t('common.viewCategoryArticles')}</span>
|
||||||
<i class="fas fa-arrow-right text-xs"></i>
|
<i class="fas fa-arrow-right text-xs"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +89,7 @@ try {
|
|||||||
) : (
|
) : (
|
||||||
<div class="terminal-empty">
|
<div class="terminal-empty">
|
||||||
<i class="fas fa-inbox text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
|
<i class="fas fa-inbox text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
|
||||||
<p class="text-[var(--text-secondary)]">暂无分类数据</p>
|
<p class="text-[var(--text-secondary)]">{t('categories.empty')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
|||||||
import FriendLinkCard from '../../components/FriendLinkCard.astro';
|
import FriendLinkCard from '../../components/FriendLinkCard.astro';
|
||||||
import FriendLinkApplication from '../../components/FriendLinkApplication.astro';
|
import FriendLinkApplication from '../../components/FriendLinkApplication.astro';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
|
import { getI18n } from '../../lib/i18n';
|
||||||
import type { AppFriendLink } from '../../lib/api/client';
|
import type { AppFriendLink } from '../../lib/api/client';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
let friendLinks: AppFriendLink[] = [];
|
let friendLinks: AppFriendLink[] = [];
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[siteSettings, friendLinks] = await Promise.all([
|
[siteSettings, friendLinks] = await Promise.all([
|
||||||
@@ -18,18 +22,18 @@ try {
|
|||||||
]);
|
]);
|
||||||
friendLinks = friendLinks.filter(friend => friend.status === 'approved');
|
friendLinks = friendLinks.filter(friend => friend.status === 'approved');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : 'Failed to fetch friend links';
|
error = e instanceof Error ? e.message : t('common.apiUnavailable');
|
||||||
console.error('Failed to fetch friend links:', e);
|
console.error('Failed to fetch friend links:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = [...new Set(friendLinks.map(friend => friend.category || '其他'))];
|
const categories = [...new Set(friendLinks.map(friend => friend.category || t('common.other')))];
|
||||||
const groupedLinks = categories.map(category => ({
|
const groupedLinks = categories.map(category => ({
|
||||||
category,
|
category,
|
||||||
links: friendLinks.filter(friend => (friend.category || '其他') === category)
|
links: friendLinks.filter(friend => (friend.category || t('common.other')) === category)
|
||||||
}));
|
}));
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title={`友情链接 - ${siteSettings.siteShortName}`} description={`与 ${siteSettings.siteName} 交换友情链接`}>
|
<BaseLayout title={`${t('friends.pageTitle')} - ${siteSettings.siteShortName}`} description={t('friends.pageDescription', { siteName: siteSettings.siteName })}>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/friends" class="w-full">
|
<TerminalWindow title="~/friends" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
@@ -41,20 +45,20 @@ const groupedLinks = categories.map(category => ({
|
|||||||
<i class="fas fa-link"></i>
|
<i class="fas fa-link"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-[var(--title-color)]">友情链接</h1>
|
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('friends.title')}</h1>
|
||||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
|
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
这里聚合已经通过审核的站点,也提供统一风格的申请面板,避免列表区和表单区像两个页面。
|
{t('friends.intro')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex flex-wrap gap-2">
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-globe text-[var(--primary)]"></i>
|
<i class="fas fa-globe text-[var(--primary)]"></i>
|
||||||
<span>{friendLinks.length} 个友链</span>
|
<span>{t('common.friendsCount', { count: friendLinks.length })}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-check-circle text-[var(--primary)]"></i>
|
<i class="fas fa-check-circle text-[var(--primary)]"></i>
|
||||||
<span>仅展示已通过审核</span>
|
<span>{t('common.reviewedOnly')}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +82,7 @@ const groupedLinks = categories.map(category => ({
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-bold text-[var(--title-color)]">{group.category}</h2>
|
<h2 class="text-lg font-bold text-[var(--title-color)]">{group.category}</h2>
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">friend collection</p>
|
<p class="text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{t('friends.collection')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="terminal-chip text-xs py-1 px-2.5">({group.links.length})</span>
|
<span class="terminal-chip text-xs py-1 px-2.5">({group.links.length})</span>
|
||||||
@@ -103,21 +107,21 @@ const groupedLinks = categories.map(category => ({
|
|||||||
<CommandPrompt command="cat ./exchange_info.txt" />
|
<CommandPrompt command="cat ./exchange_info.txt" />
|
||||||
<div class="terminal-panel ml-4 mt-4">
|
<div class="terminal-panel ml-4 mt-4">
|
||||||
<div class="terminal-kicker">exchange rules</div>
|
<div class="terminal-kicker">exchange rules</div>
|
||||||
<h3 class="mt-4 font-bold text-[var(--title-color)] text-lg">友链交换</h3>
|
<h3 class="mt-4 font-bold text-[var(--title-color)] text-lg">{t('friends.exchangeRules')}</h3>
|
||||||
<p class="mt-3 text-sm text-[var(--text-secondary)] mb-4 leading-6">
|
<p class="mt-3 text-sm text-[var(--text-secondary)] mb-4 leading-6">
|
||||||
欢迎交换友情链接!请确保您的网站满足以下条件:
|
{t('friends.exchangeIntro')}
|
||||||
</p>
|
</p>
|
||||||
<ul class="text-sm text-[var(--text-secondary)] space-y-1 list-disc list-inside">
|
<ul class="text-sm text-[var(--text-secondary)] space-y-1 list-disc list-inside">
|
||||||
<li>原创内容为主</li>
|
<li>{t('friends.rule1')}</li>
|
||||||
<li>网站稳定运行</li>
|
<li>{t('friends.rule2')}</li>
|
||||||
<li>无不良内容</li>
|
<li>{t('friends.rule3')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="mt-5 pt-4 border-t border-[var(--border-color)]">
|
<div class="mt-5 pt-4 border-t border-[var(--border-color)]">
|
||||||
<p class="text-sm text-[var(--text-tertiary)] font-mono">
|
<p class="text-sm text-[var(--text-tertiary)] font-mono">
|
||||||
本站信息:<br/>
|
{t('friends.siteInfo')}<br/>
|
||||||
名称: {siteSettings.siteName}<br/>
|
{t('friends.name')}: {siteSettings.siteName}<br/>
|
||||||
描述: {siteSettings.siteDescription}<br/>
|
{t('friends.description')}: {siteSettings.siteDescription}<br/>
|
||||||
链接: {siteSettings.siteUrl}
|
{t('friends.link')}: {siteSettings.siteUrl}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import StatsList from '../components/StatsList.astro';
|
|||||||
import TechStackList from '../components/TechStackList.astro';
|
import TechStackList from '../components/TechStackList.astro';
|
||||||
import { terminalConfig } from '../lib/config/terminal';
|
import { terminalConfig } from '../lib/config/terminal';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||||
|
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||||
import type { AppFriendLink } from '../lib/api/client';
|
import type { AppFriendLink } from '../lib/api/client';
|
||||||
import type { Post } from '../lib/types';
|
import type { Post } from '../lib/types';
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ let tags: string[] = [];
|
|||||||
let friendLinks: AppFriendLink[] = [];
|
let friendLinks: AppFriendLink[] = [];
|
||||||
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
|
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
|
||||||
let apiError: string | null = null;
|
let apiError: string | null = null;
|
||||||
|
const { locale, t } = getI18n(Astro);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
siteSettings = await api.getSiteSettings();
|
siteSettings = await api.getSiteSettings();
|
||||||
@@ -40,23 +42,32 @@ try {
|
|||||||
friendLinks = (await api.getFriendLinks()).filter(friend => friend.status === 'approved');
|
friendLinks = (await api.getFriendLinks()).filter(friend => friend.status === 'approved');
|
||||||
categories = await api.getCategories();
|
categories = await api.getCategories();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
apiError = error instanceof Error ? error.message : 'API unavailable';
|
apiError = error instanceof Error ? error.message : t('common.apiUnavailable');
|
||||||
console.error('API Error:', error);
|
console.error('API Error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemStats = [
|
const systemStats = [
|
||||||
{ label: '文章', value: String(allPosts.length) },
|
{ label: t('common.posts'), value: String(allPosts.length) },
|
||||||
{ label: '标签', value: String(tags.length) },
|
{ label: t('common.tags'), value: String(tags.length) },
|
||||||
{ label: '分类', value: String(categories.length) },
|
{ label: t('common.categories'), value: String(categories.length) },
|
||||||
{ label: '友链', value: String(friendLinks.length) },
|
{ label: t('common.friends'), value: String(friendLinks.length) },
|
||||||
];
|
];
|
||||||
|
|
||||||
const techStack = siteSettings.techStack.map(name => ({ name }));
|
const techStack = siteSettings.techStack.map(name => ({ name }));
|
||||||
|
|
||||||
const postTypeFilters = [
|
const postTypeFilters = [
|
||||||
{ id: 'all', name: '全部', icon: 'fa-stream' },
|
{ id: 'all', name: t('common.all'), icon: 'fa-stream' },
|
||||||
{ id: 'article', name: terminalConfig.postTypes.article.label, icon: 'fa-file-alt' },
|
{ id: 'article', name: t('common.article'), icon: 'fa-file-alt' },
|
||||||
{ id: 'tweet', name: terminalConfig.postTypes.tweet.label, icon: 'fa-comment-dots' }
|
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' }
|
||||||
|
];
|
||||||
|
const navLinks = [
|
||||||
|
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
||||||
|
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
|
||||||
|
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
|
||||||
|
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
|
||||||
|
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
|
||||||
|
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
|
||||||
|
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
|
||||||
];
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,7 +89,7 @@ const postTypeFilters = [
|
|||||||
<div class="mb-8 px-4">
|
<div class="mb-8 px-4">
|
||||||
<CommandPrompt command="ls -l" />
|
<CommandPrompt command="ls -l" />
|
||||||
<div class="ml-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
|
<div class="ml-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
|
||||||
{terminalConfig.navLinks.map(link => (
|
{navLinks.map(link => (
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
class="flex items-center gap-2 text-[var(--text)] hover:text-[var(--primary)] transition-colors py-2"
|
class="flex items-center gap-2 text-[var(--text)] hover:text-[var(--primary)] transition-colors py-2"
|
||||||
@@ -126,17 +137,22 @@ const postTypeFilters = [
|
|||||||
<div class="mb-8 px-4">
|
<div class="mb-8 px-4">
|
||||||
<CommandPrompt command="cat ./pinned_post.md" />
|
<CommandPrompt command="cat ./pinned_post.md" />
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<div
|
<div class="p-4 rounded-lg border border-[var(--border-color)] bg-[var(--header-bg)] transition-colors hover:border-[var(--primary)]">
|
||||||
class="p-4 rounded-lg border border-[var(--border-color)] bg-[var(--header-bg)] cursor-pointer hover:border-[var(--primary)] transition-colors"
|
|
||||||
onclick={`window.location.href='/articles/${pinnedPost.slug}'`}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<span class="px-2 py-0.5 text-xs rounded bg-[var(--primary)] text-[var(--terminal-bg)] font-bold">置顶</span>
|
<span class="px-2 py-0.5 text-xs rounded bg-[var(--primary)] text-[var(--terminal-bg)] font-bold">{t('home.pinned')}</span>
|
||||||
<span class="w-3 h-3 rounded-full" style={`background-color: ${pinnedPost.type === 'article' ? 'var(--primary)' : 'var(--secondary)'}`}></span>
|
<span class="w-3 h-3 rounded-full" style={`background-color: ${pinnedPost.type === 'article' ? 'var(--primary)' : 'var(--secondary)'}`}></span>
|
||||||
<h3 class="text-lg font-bold">{pinnedPost.title}</h3>
|
<a href={`/articles/${pinnedPost.slug}`} class="text-lg font-bold text-[var(--title-color)] transition hover:text-[var(--primary)]">
|
||||||
|
{pinnedPost.title}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[var(--text-secondary)] mb-2">{pinnedPost.date} | 阅读时间: {pinnedPost.readTime}</p>
|
<p class="text-sm text-[var(--text-secondary)] mb-2">{pinnedPost.date} | {t('common.readTime')}: {formatReadTime(locale, pinnedPost.readTime, t)}</p>
|
||||||
<p class="text-[var(--text-secondary)]">{pinnedPost.description}</p>
|
<p class="text-[var(--text-secondary)]">{pinnedPost.description}</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href={`/articles/${pinnedPost.slug}`} class="terminal-action-button inline-flex">
|
||||||
|
<i class="fas fa-angle-right"></i>
|
||||||
|
<span>{t('common.readMore')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,7 +167,7 @@ const postTypeFilters = [
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<ViewMoreLink href="/articles" text="查看所有文章" />
|
<ViewMoreLink href="/articles" text={t('common.viewAllArticles')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,7 +194,7 @@ const postTypeFilters = [
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 ml-4">
|
<div class="mt-6 ml-4">
|
||||||
<ViewMoreLink href="/friends" text="查看全部友链" />
|
<ViewMoreLink href="/friends" text={t('common.viewAllLinks')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,15 +205,15 @@ const postTypeFilters = [
|
|||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">关于我</h3>
|
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.about')}</h3>
|
||||||
<p class="text-[var(--text-secondary)] mb-4">{siteSettings.ownerBio}</p>
|
<p class="text-[var(--text-secondary)] mb-4">{siteSettings.ownerBio}</p>
|
||||||
|
|
||||||
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">技术栈</h3>
|
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.techStack')}</h3>
|
||||||
<TechStackList items={techStack} />
|
<TechStackList items={techStack} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">系统状态</h3>
|
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.systemStatus')}</h3>
|
||||||
<StatsList stats={systemStats} />
|
<StatsList stats={systemStats} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
|||||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||||
import InfoTile from '../../components/ui/InfoTile.astro';
|
import InfoTile from '../../components/ui/InfoTile.astro';
|
||||||
import { apiClient } from '../../lib/api/client';
|
import { apiClient } from '../../lib/api/client';
|
||||||
|
import { getI18n } from '../../lib/i18n';
|
||||||
import type { Review } from '../../lib/api/client';
|
import type { Review } from '../../lib/api/client';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
type ParsedReview = Omit<Review, 'tags'> & {
|
type ParsedReview = Omit<Review, 'tags'> & {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
};
|
};
|
||||||
@@ -15,6 +18,7 @@ type ParsedReview = Omit<Review, 'tags'> & {
|
|||||||
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
|
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
|
||||||
const url = new URL(Astro.request.url);
|
const url = new URL(Astro.request.url);
|
||||||
const selectedType = url.searchParams.get('type') || 'all';
|
const selectedType = url.searchParams.get('type') || 'all';
|
||||||
|
const { t } = getI18n(Astro);
|
||||||
try {
|
try {
|
||||||
reviews = await apiClient.getReviews();
|
reviews = await apiClient.getReviews();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -41,20 +45,20 @@ const stats = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
{ id: 'all', name: '全部', icon: 'fa-list', count: parsedReviews.length },
|
{ id: 'all', name: t('reviews.typeAll'), icon: 'fa-list', count: parsedReviews.length },
|
||||||
{ id: 'game', name: '游戏', icon: 'fa-gamepad', count: parsedReviews.filter(r => r.review_type === 'game').length },
|
{ id: 'game', name: t('reviews.typeGame'), icon: 'fa-gamepad', count: parsedReviews.filter(r => r.review_type === 'game').length },
|
||||||
{ id: 'anime', name: '动画', icon: 'fa-tv', count: parsedReviews.filter(r => r.review_type === 'anime').length },
|
{ id: 'anime', name: t('reviews.typeAnime'), icon: 'fa-tv', count: parsedReviews.filter(r => r.review_type === 'anime').length },
|
||||||
{ id: 'music', name: '音乐', icon: 'fa-music', count: parsedReviews.filter(r => r.review_type === 'music').length },
|
{ id: 'music', name: t('reviews.typeMusic'), icon: 'fa-music', count: parsedReviews.filter(r => r.review_type === 'music').length },
|
||||||
{ id: 'book', name: '书籍', icon: 'fa-book', count: parsedReviews.filter(r => r.review_type === 'book').length },
|
{ id: 'book', name: t('reviews.typeBook'), icon: 'fa-book', count: parsedReviews.filter(r => r.review_type === 'book').length },
|
||||||
{ id: 'movie', name: '影视', icon: 'fa-film', count: parsedReviews.filter(r => r.review_type === 'movie').length }
|
{ id: 'movie', name: t('reviews.typeMovie'), icon: 'fa-film', count: parsedReviews.filter(r => r.review_type === 'movie').length }
|
||||||
];
|
];
|
||||||
|
|
||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
game: '游戏',
|
game: t('reviews.typeGame'),
|
||||||
anime: '动画',
|
anime: t('reviews.typeAnime'),
|
||||||
music: '音乐',
|
music: t('reviews.typeMusic'),
|
||||||
book: '书籍',
|
book: t('reviews.typeBook'),
|
||||||
movie: '影视'
|
movie: t('reviews.typeMovie')
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
const typeColors: Record<string, string> = {
|
||||||
@@ -66,7 +70,7 @@ const typeColors: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="评价 | Termi" description="记录游戏、音乐、动画、书籍的体验与评价">
|
<Layout title={`${t('reviews.pageTitle')} | Termi`} description={t('reviews.pageDescription')}>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Single Terminal Window for entire page -->
|
<!-- Single Terminal Window for entire page -->
|
||||||
<TerminalWindow title="~/reviews" class="w-full">
|
<TerminalWindow title="~/reviews" class="w-full">
|
||||||
@@ -82,10 +86,10 @@ const typeColors: Record<string, string> = {
|
|||||||
<i class="fas fa-star"></i>
|
<i class="fas fa-star"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-[var(--title-color)]">评价</h1>
|
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('reviews.title')}</h1>
|
||||||
<p id="reviews-subtitle" class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
<p id="reviews-subtitle" class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
记录游戏、音乐、动画、书籍的体验与感悟
|
{t('reviews.subtitle')}
|
||||||
{selectedType !== 'all' && ` · 当前筛选: ${typeLabels[selectedType] || selectedType}`}
|
{selectedType !== 'all' && ` · ${t('reviews.currentFilter', { type: typeLabels[selectedType] || selectedType })}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,19 +101,19 @@ const typeColors: Record<string, string> = {
|
|||||||
<div class="ml-4 mt-2 grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div class="ml-4 mt-2 grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
<InfoTile tone="blue" layout="stack">
|
<InfoTile tone="blue" layout="stack">
|
||||||
<div id="reviews-total" class="text-2xl font-bold text-[var(--primary)]">{stats.total}</div>
|
<div id="reviews-total" class="text-2xl font-bold text-[var(--primary)]">{stats.total}</div>
|
||||||
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">总评价</div>
|
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.total')}</div>
|
||||||
</InfoTile>
|
</InfoTile>
|
||||||
<InfoTile tone="amber" layout="stack">
|
<InfoTile tone="amber" layout="stack">
|
||||||
<div id="reviews-average" class="text-2xl font-bold text-yellow-500">{stats.avgRating}</div>
|
<div id="reviews-average" class="text-2xl font-bold text-yellow-500">{stats.avgRating}</div>
|
||||||
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">平均评分</div>
|
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.average')}</div>
|
||||||
</InfoTile>
|
</InfoTile>
|
||||||
<InfoTile tone="teal" layout="stack">
|
<InfoTile tone="teal" layout="stack">
|
||||||
<div id="reviews-completed" class="text-2xl font-bold text-[var(--success)]">{stats.completed}</div>
|
<div id="reviews-completed" class="text-2xl font-bold text-[var(--success)]">{stats.completed}</div>
|
||||||
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">已完成</div>
|
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.completed')}</div>
|
||||||
</InfoTile>
|
</InfoTile>
|
||||||
<InfoTile tone="violet" layout="stack">
|
<InfoTile tone="violet" layout="stack">
|
||||||
<div id="reviews-progress" class="text-2xl font-bold text-[var(--warning)]">{stats.inProgress}</div>
|
<div id="reviews-progress" class="text-2xl font-bold text-[var(--warning)]">{stats.inProgress}</div>
|
||||||
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">进行中</div>
|
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.inProgress')}</div>
|
||||||
</InfoTile>
|
</InfoTile>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +150,7 @@ const typeColors: Record<string, string> = {
|
|||||||
<i class="fas fa-inbox"></i>
|
<i class="fas fa-inbox"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[var(--text-secondary)]">
|
<div class="text-[var(--text-secondary)]">
|
||||||
{parsedReviews.length === 0 ? '暂无评价数据,请检查后端 API 连接' : '当前筛选下暂无评价'}
|
{parsedReviews.length === 0 ? t('reviews.emptyData') : t('reviews.emptyFiltered')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -201,7 +205,7 @@ const typeColors: Record<string, string> = {
|
|||||||
<div class="text-3xl text-[var(--primary)] mb-3">
|
<div class="text-3xl text-[var(--primary)] mb-3">
|
||||||
<i class="fas fa-inbox"></i>
|
<i class="fas fa-inbox"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[var(--text-secondary)]">当前筛选下暂无评价</div>
|
<div class="text-[var(--text-secondary)]">{t('reviews.emptyFiltered')}</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -218,16 +222,22 @@ const typeColors: Record<string, string> = {
|
|||||||
</div>
|
</div>
|
||||||
</TerminalWindow>
|
</TerminalWindow>
|
||||||
</div>
|
</div>
|
||||||
<script is:inline>
|
<script
|
||||||
|
is:inline
|
||||||
|
define:vars={{
|
||||||
|
reviewTypeLabels: {
|
||||||
|
game: t('reviews.typeGame'),
|
||||||
|
anime: t('reviews.typeAnime'),
|
||||||
|
music: t('reviews.typeMusic'),
|
||||||
|
book: t('reviews.typeBook'),
|
||||||
|
movie: t('reviews.typeMovie'),
|
||||||
|
all: t('reviews.typeAll'),
|
||||||
|
},
|
||||||
|
reviewsBaseSubtitle: t('reviews.subtitle'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
(function() {
|
(function() {
|
||||||
const typeLabels = {
|
const typeLabels = reviewTypeLabels;
|
||||||
game: '游戏',
|
|
||||||
anime: '动画',
|
|
||||||
music: '音乐',
|
|
||||||
book: '书籍',
|
|
||||||
movie: '影视',
|
|
||||||
all: '全部'
|
|
||||||
};
|
|
||||||
|
|
||||||
const cards = Array.from(document.querySelectorAll('[data-review-card]'));
|
const cards = Array.from(document.querySelectorAll('[data-review-card]'));
|
||||||
const filters = Array.from(document.querySelectorAll('[data-review-filter]'));
|
const filters = Array.from(document.querySelectorAll('[data-review-filter]'));
|
||||||
@@ -237,7 +247,8 @@ const typeColors: Record<string, string> = {
|
|||||||
const completedEl = document.getElementById('reviews-completed');
|
const completedEl = document.getElementById('reviews-completed');
|
||||||
const progressEl = document.getElementById('reviews-progress');
|
const progressEl = document.getElementById('reviews-progress');
|
||||||
const emptyState = document.getElementById('reviews-empty-state');
|
const emptyState = document.getElementById('reviews-empty-state');
|
||||||
const baseSubtitle = '记录游戏、音乐、动画、书籍的体验与感悟';
|
const t = window.__termiTranslate;
|
||||||
|
const baseSubtitle = reviewsBaseSubtitle;
|
||||||
|
|
||||||
function updateFilterUi(activeType) {
|
function updateFilterUi(activeType) {
|
||||||
filters.forEach((filter) => {
|
filters.forEach((filter) => {
|
||||||
@@ -277,7 +288,7 @@ const typeColors: Record<string, string> = {
|
|||||||
if (subtitle) {
|
if (subtitle) {
|
||||||
subtitle.textContent = type === 'all'
|
subtitle.textContent = type === 'all'
|
||||||
? baseSubtitle
|
? baseSubtitle
|
||||||
: `${baseSubtitle} · 当前筛选: ${typeLabels[type] || type}`;
|
: `${baseSubtitle} · ${t('reviews.currentFilter', { type: typeLabels[type] || type })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pushState) {
|
if (pushState) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
|||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||||
import { apiClient } from '../../lib/api/client';
|
import { apiClient } from '../../lib/api/client';
|
||||||
|
import { getI18n, formatReadTime } from '../../lib/i18n';
|
||||||
import type { Post, Tag } from '../../lib/types';
|
import type { Post, Tag } from '../../lib/types';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
@@ -11,6 +12,7 @@ export const prerender = false;
|
|||||||
// Fetch tags from backend
|
// Fetch tags from backend
|
||||||
let tags: Tag[] = [];
|
let tags: Tag[] = [];
|
||||||
let filteredPosts: Post[] = [];
|
let filteredPosts: Post[] = [];
|
||||||
|
const { locale, t } = getI18n(Astro);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
tags = await apiClient.getTags();
|
tags = await apiClient.getTags();
|
||||||
@@ -35,7 +37,7 @@ if (selectedTag) {
|
|||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout title="标签 - Termi">
|
<BaseLayout title={`${t('tags.pageTitle')} - Termi`}>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/tags" class="w-full">
|
<TerminalWindow title="~/tags" class="w-full">
|
||||||
<div class="mb-6 px-4">
|
<div class="mb-6 px-4">
|
||||||
@@ -47,21 +49,21 @@ if (selectedTag) {
|
|||||||
<i class="fas fa-hashtag"></i>
|
<i class="fas fa-hashtag"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-[var(--title-color)]">标签云</h1>
|
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('tags.title')}</h1>
|
||||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
|
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
用更轻量的关键词维度检索文章。选中标签时,下方结果区会延续同一套终端卡片风格。
|
{t('tags.intro')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex flex-wrap gap-2">
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-tags text-[var(--primary)]"></i>
|
<i class="fas fa-tags text-[var(--primary)]"></i>
|
||||||
<span>{tags.length} 个标签</span>
|
<span>{t('common.tagsCount', { count: tags.length })}</span>
|
||||||
</span>
|
</span>
|
||||||
{selectedTag && (
|
{selectedTag && (
|
||||||
<span class="terminal-stat-pill">
|
<span class="terminal-stat-pill">
|
||||||
<i class="fas fa-filter text-[var(--primary)]"></i>
|
<i class="fas fa-filter text-[var(--primary)]"></i>
|
||||||
<span>当前: #{selectedTag}</span>
|
<span>{t('tags.currentTag', { tag: selectedTag })}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -74,12 +76,11 @@ if (selectedTag) {
|
|||||||
<div class="terminal-panel ml-4 mt-4">
|
<div class="terminal-panel ml-4 mt-4">
|
||||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<p class="text-[var(--text-secondary)] leading-6">
|
<p class="text-[var(--text-secondary)] leading-6">
|
||||||
标签 <span class="text-[var(--primary)] font-bold">#{selectedTag}</span>
|
{t('tags.selectedSummary', { tag: selectedTag, count: filteredPosts.length })}
|
||||||
找到 {filteredPosts.length} 篇文章
|
|
||||||
</p>
|
</p>
|
||||||
<FilterPill tone="teal" href="/tags">
|
<FilterPill tone="teal" href="/tags">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
<span>清除筛选</span>
|
<span>{t('common.clearFilters')}</span>
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,12 +90,12 @@ if (selectedTag) {
|
|||||||
<div class="px-4 mb-8">
|
<div class="px-4 mb-8">
|
||||||
<div class="terminal-panel ml-4 mt-4">
|
<div class="terminal-panel ml-4 mt-4">
|
||||||
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-4">
|
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-4">
|
||||||
browse tags
|
{t('tags.browseTags')}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<div class="terminal-empty w-full">
|
<div class="terminal-empty w-full">
|
||||||
暂无标签数据
|
{t('tags.emptyTags')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
tags.map(tag => (
|
tags.map(tag => (
|
||||||
@@ -129,10 +130,10 @@ if (selectedTag) {
|
|||||||
<span>{post.category}</span>
|
<span>{post.category}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[var(--text-secondary)]">{post.date} | {post.readTime}</p>
|
<p class="text-sm text-[var(--text-secondary)]">{post.date} | {formatReadTime(locale, post.readTime, t)}</p>
|
||||||
<p class="text-sm text-[var(--text-secondary)] mt-3 leading-6">{post.description}</p>
|
<p class="text-sm text-[var(--text-secondary)] mt-3 leading-6">{post.description}</p>
|
||||||
<div class="mt-4 terminal-link-arrow">
|
<div class="mt-4 terminal-link-arrow">
|
||||||
<span>打开文章</span>
|
<span>{t('common.viewArticle')}</span>
|
||||||
<i class="fas fa-arrow-right text-xs"></i>
|
<i class="fas fa-arrow-right text-xs"></i>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -147,7 +148,7 @@ if (selectedTag) {
|
|||||||
<div class="border-t border-[var(--border-color)] pt-6">
|
<div class="border-t border-[var(--border-color)] pt-6">
|
||||||
<div class="terminal-empty ml-4 mt-4">
|
<div class="terminal-empty ml-4 mt-4">
|
||||||
<i class="fas fa-search text-4xl text-[var(--text-tertiary)] mb-4"></i>
|
<i class="fas fa-search text-4xl text-[var(--text-tertiary)] mb-4"></i>
|
||||||
<p class="text-[var(--text-secondary)]">没有找到该标签的文章</p>
|
<p class="text-[var(--text-secondary)]">{t('tags.emptyPosts')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import TerminalWindow from '../../components/ui/TerminalWindow.astro';
|
|||||||
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
|
||||||
import FilterPill from '../../components/ui/FilterPill.astro';
|
import FilterPill from '../../components/ui/FilterPill.astro';
|
||||||
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
||||||
|
import { getI18n, formatReadTime } from '../../lib/i18n';
|
||||||
import type { Post } from '../../lib/types';
|
import type { Post } from '../../lib/types';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
let posts: Post[] = [];
|
let posts: Post[] = [];
|
||||||
|
const { locale, t } = getI18n(Astro);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[siteSettings, posts] = await Promise.all([
|
[siteSettings, posts] = await Promise.all([
|
||||||
@@ -29,7 +33,7 @@ const years = Object.keys(groupedByYear).sort((a, b) => Number(b) - Number(a));
|
|||||||
const latestYear = years[0] || 'all';
|
const latestYear = years[0] || 'all';
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={`时间轴 | ${siteSettings.siteShortName}`} description={`记录 ${siteSettings.ownerName} 的技术成长与生活点滴`}>
|
<Layout title={`${t('timeline.pageTitle')} | ${siteSettings.siteShortName}`} description={t('timeline.pageDescription', { ownerName: siteSettings.ownerName })}>
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<TerminalWindow title="~/timeline" class="w-full">
|
<TerminalWindow title="~/timeline" class="w-full">
|
||||||
<div class="px-4 py-4 space-y-6">
|
<div class="px-4 py-4 space-y-6">
|
||||||
@@ -42,9 +46,9 @@ const latestYear = years[0] || 'all';
|
|||||||
<i class="fas fa-stream"></i>
|
<i class="fas fa-stream"></i>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-[var(--title-color)]">时间轴</h1>
|
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('timeline.title')}</h1>
|
||||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
共 {posts.length} 篇内容 · 记录 {siteSettings.ownerName} 的技术成长与生活点滴
|
{t('timeline.subtitle', { count: posts.length, ownerName: siteSettings.ownerName })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +64,7 @@ const latestYear = years[0] || 'all';
|
|||||||
data-year="all"
|
data-year="all"
|
||||||
active={false}
|
active={false}
|
||||||
>
|
>
|
||||||
全部
|
{t('timeline.allYears')}
|
||||||
</FilterPill>
|
</FilterPill>
|
||||||
{years.map(year => (
|
{years.map(year => (
|
||||||
<FilterPill
|
<FilterPill
|
||||||
@@ -102,7 +106,7 @@ const latestYear = years[0] || 'all';
|
|||||||
>
|
>
|
||||||
<div class="terminal-panel-muted shrink-0 min-w-[72px] text-center py-3">
|
<div class="terminal-panel-muted shrink-0 min-w-[72px] text-center py-3">
|
||||||
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||||
{new Date(post.date).toLocaleDateString('zh-CN', { month: 'short' })}
|
{new Date(post.date).toLocaleDateString(locale, { month: 'short' })}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-2xl font-bold text-[var(--primary)]">
|
<div class="mt-1 text-2xl font-bold text-[var(--primary)]">
|
||||||
{new Date(post.date).getDate()}
|
{new Date(post.date).getDate()}
|
||||||
@@ -125,7 +129,7 @@ const latestYear = years[0] || 'all';
|
|||||||
{post.category}
|
{post.category}
|
||||||
</span>
|
</span>
|
||||||
<span class="terminal-chip text-xs py-1 px-2.5">
|
<span class="terminal-chip text-xs py-1 px-2.5">
|
||||||
{post.readTime}
|
{formatReadTime(locale, post.readTime, t)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -674,6 +674,167 @@ html.dark {
|
|||||||
.ui-info-tile--neutral {
|
.ui-info-tile--neutral {
|
||||||
--tile-rgb: 100 116 139;
|
--tile-rgb: 100 116 139;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paragraph-comments-intro {
|
||||||
|
@apply flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comments-shell {
|
||||||
|
@apply space-y-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comments-summary {
|
||||||
|
@apply shrink-0 self-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-paragraph {
|
||||||
|
position: relative;
|
||||||
|
scroll-margin-top: 8rem;
|
||||||
|
transition:
|
||||||
|
color 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-paragraph.is-comment-focused {
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: color-mix(in oklab, var(--primary) 7%, transparent);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px color-mix(in oklab, var(--primary) 14%, var(--border-color)),
|
||||||
|
0 12px 26px rgba(var(--text-rgb), 0.04);
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
margin-left: -0.6rem;
|
||||||
|
margin-right: -0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-row {
|
||||||
|
@apply mt-3 flex flex-col gap-2 rounded-lg border px-3 py-2 sm:flex-row sm:items-center sm:justify-between;
|
||||||
|
border-color: color-mix(in oklab, var(--primary) 9%, var(--border-color));
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-row:hover {
|
||||||
|
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-row.is-active {
|
||||||
|
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(var(--primary-rgb), 0.08),
|
||||||
|
0 10px 24px rgba(var(--text-rgb), 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-command {
|
||||||
|
@apply min-w-0 font-mono text-[11px] leading-6 sm:text-[12px];
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-prompt {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-command-text {
|
||||||
|
color: var(--title-color);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-actions {
|
||||||
|
@apply flex flex-wrap items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-hint {
|
||||||
|
@apply inline-flex items-center rounded-md border px-2 py-1 text-[10px] font-mono uppercase tracking-[0.18em];
|
||||||
|
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
background: color-mix(in oklab, var(--header-bg) 84%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-panel {
|
||||||
|
@apply mt-4 space-y-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-panel-head {
|
||||||
|
@apply flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-panel-meta {
|
||||||
|
@apply flex flex-wrap items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-thread {
|
||||||
|
@apply space-y-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-thread-segment {
|
||||||
|
@apply space-y-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-thread-segment-label {
|
||||||
|
@apply inline-flex items-center rounded-md border px-2 py-1 text-[10px] font-mono uppercase tracking-[0.18em];
|
||||||
|
border-color: color-mix(in oklab, var(--primary) 9%, var(--border-color));
|
||||||
|
background: color-mix(in oklab, var(--header-bg) 82%, transparent);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-thread-item {
|
||||||
|
@apply rounded-xl border p-4;
|
||||||
|
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-thread-item.is-reply {
|
||||||
|
border-color: color-mix(in oklab, var(--secondary) 18%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-thread-item.is-pending {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: color-mix(in oklab, var(--warning) 22%, var(--border-color));
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in oklab, var(--warning) 4%, var(--terminal-bg)), color-mix(in oklab, var(--header-bg) 88%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-thread-head {
|
||||||
|
@apply mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-thread-quote {
|
||||||
|
@apply rounded-lg border-l-2 px-3 py-2 text-xs leading-6;
|
||||||
|
border-left-color: color-mix(in oklab, var(--primary) 34%, var(--border-color));
|
||||||
|
background: color-mix(in oklab, var(--primary) 6%, var(--header-bg));
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-form {
|
||||||
|
@apply space-y-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-reply {
|
||||||
|
@apply flex items-center justify-between gap-3 py-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-status {
|
||||||
|
@apply rounded-xl border px-4 py-3 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-status-success {
|
||||||
|
border-color: color-mix(in oklab, var(--success) 28%, var(--border-color));
|
||||||
|
background: color-mix(in oklab, var(--success) 10%, var(--header-bg));
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-status-error {
|
||||||
|
border-color: color-mix(in oklab, var(--danger) 28%, var(--border-color));
|
||||||
|
background: color-mix(in oklab, var(--danger) 10%, var(--header-bg));
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph-comment-status-info {
|
||||||
|
border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));
|
||||||
|
background: color-mix(in oklab, var(--primary) 10%, var(--header-bg));
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animations */
|
/* Animations */
|
||||||
|
|||||||
1142
mcp-server/package-lock.json
generated
Normal file
1142
mcp-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
mcp-server/package.json
Normal file
16
mcp-server/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "termi-mcp-server",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
518
mcp-server/server.js
Normal file
518
mcp-server/server.js
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
|
||||||
|
import * as z from 'zod/v4';
|
||||||
|
|
||||||
|
const DEFAULT_PORT = 5151;
|
||||||
|
const DEFAULT_HOST = '127.0.0.1';
|
||||||
|
const DEFAULT_BACKEND_API_BASE = 'http://127.0.0.1:5150/api';
|
||||||
|
const DEFAULT_API_KEY = 'termi-mcp-local-dev-key';
|
||||||
|
|
||||||
|
const HOST = process.env.TERMI_MCP_HOST || DEFAULT_HOST;
|
||||||
|
const PORT = Number.parseInt(process.env.TERMI_MCP_PORT || `${DEFAULT_PORT}`, 10);
|
||||||
|
const API_BASE = (process.env.TERMI_BACKEND_API_BASE || DEFAULT_BACKEND_API_BASE).replace(/\/+$/, '');
|
||||||
|
const API_KEY = process.env.TERMI_MCP_API_KEY || DEFAULT_API_KEY;
|
||||||
|
|
||||||
|
function unauthorizedResponse(res) {
|
||||||
|
res.status(401).json({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Missing or invalid MCP API key'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyApiKey(req, res, next) {
|
||||||
|
const authorization = req.headers.authorization;
|
||||||
|
const bearerToken = typeof authorization === 'string' && authorization.startsWith('Bearer ')
|
||||||
|
? authorization.slice('Bearer '.length).trim()
|
||||||
|
: null;
|
||||||
|
const xApiKey = typeof req.headers['x-api-key'] === 'string' ? req.headers['x-api-key'].trim() : null;
|
||||||
|
const token = bearerToken || xApiKey;
|
||||||
|
|
||||||
|
if (!token || token !== API_KEY) {
|
||||||
|
unauthorizedResponse(res);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBackendResponse(response) {
|
||||||
|
const rawText = await response.text();
|
||||||
|
|
||||||
|
if (!rawText) {
|
||||||
|
if (response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Backend request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawText);
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = parsed.description || parsed.error || parsed.message || rawText;
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(rawText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
return rawText;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestBackend(method, path, body) {
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {})
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return readBackendResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createToolResult(title, data) {
|
||||||
|
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||||
|
const structuredContent = typeof data === 'string'
|
||||||
|
? { text: data }
|
||||||
|
: Array.isArray(data)
|
||||||
|
? { items: data }
|
||||||
|
: data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `${title}\n${text}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
structuredContent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPostsQuery(args) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
const pairs = [
|
||||||
|
['slug', args.slug],
|
||||||
|
['category', args.category],
|
||||||
|
['tag', args.tag],
|
||||||
|
['search', args.search],
|
||||||
|
['type', args.postType]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [key, value] of pairs) {
|
||||||
|
if (typeof value === 'string' && value.trim()) {
|
||||||
|
query.set(key, value.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof args.pinned === 'boolean') {
|
||||||
|
query.set('pinned', `${args.pinned}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = query.toString();
|
||||||
|
return serialized ? `?${serialized}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServer() {
|
||||||
|
const server = new McpServer(
|
||||||
|
{
|
||||||
|
name: 'termi-blog-mcp',
|
||||||
|
version: '0.1.0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
logging: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'posts_list',
|
||||||
|
{
|
||||||
|
title: 'List Posts',
|
||||||
|
description: '列出博客文章,可按 slug、分类、标签、搜索词和类型过滤。',
|
||||||
|
inputSchema: {
|
||||||
|
slug: z.string().optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
tag: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
postType: z.string().optional(),
|
||||||
|
pinned: z.boolean().optional()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const data = await requestBackend('GET', `/posts${buildPostsQuery(args)}`);
|
||||||
|
return createToolResult('Posts list', data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'post_get',
|
||||||
|
{
|
||||||
|
title: 'Get Post',
|
||||||
|
description: '按文章 slug 获取结构化文章信息。',
|
||||||
|
inputSchema: {
|
||||||
|
slug: z.string().min(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ slug }) => {
|
||||||
|
const data = await requestBackend('GET', `/posts/slug/${encodeURIComponent(slug)}`);
|
||||||
|
return createToolResult(`Post ${slug}`, data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'post_get_markdown',
|
||||||
|
{
|
||||||
|
title: 'Get Post Markdown',
|
||||||
|
description: '读取文章对应的 Markdown 文件内容和路径。',
|
||||||
|
inputSchema: {
|
||||||
|
slug: z.string().min(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ slug }) => {
|
||||||
|
const data = await requestBackend(
|
||||||
|
'GET',
|
||||||
|
`/posts/slug/${encodeURIComponent(slug)}/markdown`
|
||||||
|
);
|
||||||
|
return createToolResult(`Post markdown ${slug}`, data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'post_create_markdown',
|
||||||
|
{
|
||||||
|
title: 'Create Markdown Post',
|
||||||
|
description: '创建新的 Markdown 文章文件,并同步到博客数据库。',
|
||||||
|
inputSchema: {
|
||||||
|
title: z.string().min(1),
|
||||||
|
slug: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
category: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
postType: z.string().optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
pinned: z.boolean().optional(),
|
||||||
|
published: z.boolean().optional()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const data = await requestBackend('POST', '/posts/markdown', args);
|
||||||
|
return createToolResult('Created markdown post', data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'post_update_markdown',
|
||||||
|
{
|
||||||
|
title: 'Update Post Markdown',
|
||||||
|
description: '直接更新文章 Markdown 文件内容,适合 frontmatter 或正文整体重写。',
|
||||||
|
inputSchema: {
|
||||||
|
slug: z.string().min(1),
|
||||||
|
markdown: z.string().min(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ slug, markdown }) => {
|
||||||
|
const data = await requestBackend(
|
||||||
|
'PUT',
|
||||||
|
`/posts/slug/${encodeURIComponent(slug)}/markdown`,
|
||||||
|
{ markdown }
|
||||||
|
);
|
||||||
|
return createToolResult(`Updated markdown ${slug}`, data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'post_delete_markdown',
|
||||||
|
{
|
||||||
|
title: 'Delete Post Markdown',
|
||||||
|
description: '删除文章 Markdown 文件,并同步删除数据库中的文章记录。',
|
||||||
|
inputSchema: {
|
||||||
|
slug: z.string().min(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ slug }) => {
|
||||||
|
const data = await requestBackend(
|
||||||
|
'DELETE',
|
||||||
|
`/posts/slug/${encodeURIComponent(slug)}/markdown`
|
||||||
|
);
|
||||||
|
return createToolResult(`Deleted markdown ${slug}`, data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'categories_list',
|
||||||
|
{
|
||||||
|
title: 'List Categories',
|
||||||
|
description: '列出所有分类和文章数量。',
|
||||||
|
inputSchema: {}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const data = await requestBackend('GET', '/categories');
|
||||||
|
return createToolResult('Categories list', data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'category_create',
|
||||||
|
{
|
||||||
|
title: 'Create Category',
|
||||||
|
description: '创建新的分类。',
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().optional()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const data = await requestBackend('POST', '/categories', args);
|
||||||
|
return createToolResult('Created category', data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'category_update',
|
||||||
|
{
|
||||||
|
title: 'Update Category',
|
||||||
|
description: '更新分类名称或 slug,并同步更新引用它的文章 frontmatter。',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().optional()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ id, ...payload }) => {
|
||||||
|
const data = await requestBackend('PATCH', `/categories/${id}`, payload);
|
||||||
|
return createToolResult(`Updated category ${id}`, data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'category_delete',
|
||||||
|
{
|
||||||
|
title: 'Delete Category',
|
||||||
|
description: '删除分类,并移除文章 frontmatter 中对该分类的引用。',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().int().positive()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
await requestBackend('DELETE', `/categories/${id}`);
|
||||||
|
return createToolResult(`Deleted category ${id}`, { id, deleted: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'tags_list',
|
||||||
|
{
|
||||||
|
title: 'List Tags',
|
||||||
|
description: '列出所有标签。',
|
||||||
|
inputSchema: {}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const data = await requestBackend('GET', '/tags');
|
||||||
|
return createToolResult('Tags list', data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'tag_create',
|
||||||
|
{
|
||||||
|
title: 'Create Tag',
|
||||||
|
description: '创建新的标签。',
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().min(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const data = await requestBackend('POST', '/tags', args);
|
||||||
|
return createToolResult('Created tag', data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'tag_update',
|
||||||
|
{
|
||||||
|
title: 'Update Tag',
|
||||||
|
description: '更新标签名称或 slug,并同步更新文章 frontmatter 里的标签。',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
slug: z.string().min(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ id, ...payload }) => {
|
||||||
|
const data = await requestBackend('PATCH', `/tags/${id}`, payload);
|
||||||
|
return createToolResult(`Updated tag ${id}`, data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'tag_delete',
|
||||||
|
{
|
||||||
|
title: 'Delete Tag',
|
||||||
|
description: '删除标签,并从文章 frontmatter 中移除它。',
|
||||||
|
inputSchema: {
|
||||||
|
id: z.number().int().positive()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async ({ id }) => {
|
||||||
|
await requestBackend('DELETE', `/tags/${id}`);
|
||||||
|
return createToolResult(`Deleted tag ${id}`, { id, deleted: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'site_settings_get',
|
||||||
|
{
|
||||||
|
title: 'Get Site Settings',
|
||||||
|
description: '获取前台公开的站点设置。',
|
||||||
|
inputSchema: {}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const data = await requestBackend('GET', '/site_settings');
|
||||||
|
return createToolResult('Site settings', data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'site_settings_update',
|
||||||
|
{
|
||||||
|
title: 'Update Site Settings',
|
||||||
|
description: '更新站点设置,包括网站资料和 AI 开关等字段。',
|
||||||
|
inputSchema: {
|
||||||
|
siteName: z.string().optional(),
|
||||||
|
siteShortName: z.string().optional(),
|
||||||
|
siteUrl: z.string().optional(),
|
||||||
|
siteTitle: z.string().optional(),
|
||||||
|
siteDescription: z.string().optional(),
|
||||||
|
heroTitle: z.string().optional(),
|
||||||
|
heroSubtitle: z.string().optional(),
|
||||||
|
ownerName: z.string().optional(),
|
||||||
|
ownerTitle: z.string().optional(),
|
||||||
|
ownerBio: z.string().optional(),
|
||||||
|
ownerAvatarUrl: z.string().optional(),
|
||||||
|
socialGithub: z.string().optional(),
|
||||||
|
socialTwitter: z.string().optional(),
|
||||||
|
socialEmail: z.string().optional(),
|
||||||
|
location: z.string().optional(),
|
||||||
|
techStack: z.array(z.string()).optional(),
|
||||||
|
aiEnabled: z.boolean().optional(),
|
||||||
|
aiProvider: z.string().optional(),
|
||||||
|
aiApiBase: z.string().optional(),
|
||||||
|
aiApiKey: z.string().optional(),
|
||||||
|
aiChatModel: z.string().optional(),
|
||||||
|
aiSystemPrompt: z.string().optional(),
|
||||||
|
aiTopK: z.number().int().min(1).max(12).optional(),
|
||||||
|
aiChunkSize: z.number().int().min(400).max(4000).optional()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const data = await requestBackend('PATCH', '/site_settings', args);
|
||||||
|
return createToolResult('Updated site settings', data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
'ai_reindex',
|
||||||
|
{
|
||||||
|
title: 'Rebuild AI Index',
|
||||||
|
description: '重建博客 AI 检索索引。',
|
||||||
|
inputSchema: {}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const data = await requestBackend('POST', '/ai/reindex');
|
||||||
|
return createToolResult('AI index rebuilt', data);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = createMcpExpressApp({ host: HOST });
|
||||||
|
app.use('/mcp', verifyApiKey);
|
||||||
|
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
name: 'termi-blog-mcp',
|
||||||
|
host: HOST,
|
||||||
|
port: PORT,
|
||||||
|
apiBase: API_BASE
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/mcp', async (req, res) => {
|
||||||
|
const server = getServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
|
||||||
|
res.on('close', () => {
|
||||||
|
transport.close().catch(() => {});
|
||||||
|
server.close().catch(() => {});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MCP request failed:', error);
|
||||||
|
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: {
|
||||||
|
code: -32603,
|
||||||
|
message: error instanceof Error ? error.message : 'Internal server error'
|
||||||
|
},
|
||||||
|
id: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/mcp', (_req, res) => {
|
||||||
|
res.status(405).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: {
|
||||||
|
code: -32000,
|
||||||
|
message: 'Method not allowed.'
|
||||||
|
},
|
||||||
|
id: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/mcp', (_req, res) => {
|
||||||
|
res.status(405).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: {
|
||||||
|
code: -32000,
|
||||||
|
message: 'Method not allowed.'
|
||||||
|
},
|
||||||
|
id: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, (error) => {
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to start Termi MCP server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Termi MCP server listening on http://${HOST}:${PORT}/mcp`);
|
||||||
|
console.log(`Backend API base: ${API_BASE}`);
|
||||||
|
console.log(`Using API key: ${API_KEY}`);
|
||||||
|
});
|
||||||
76
restart-services.ps1
Normal file
76
restart-services.ps1
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
param(
|
||||||
|
[switch]$FrontendOnly,
|
||||||
|
[switch]$BackendOnly,
|
||||||
|
[switch]$McpOnly,
|
||||||
|
[switch]$WithMcp,
|
||||||
|
[string]$DatabaseUrl = "postgres://postgres:postgres%402025%21@10.0.0.2:5432/termi-api_development",
|
||||||
|
[string]$McpApiKey = "termi-mcp-local-dev-key",
|
||||||
|
[string]$BackendApiBase = "http://127.0.0.1:5150/api",
|
||||||
|
[int]$McpPort = 5151
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$stopScript = Join-Path $repoRoot "stop-services.ps1"
|
||||||
|
$devScript = Join-Path $repoRoot "dev.ps1"
|
||||||
|
$frontendScript = Join-Path $repoRoot "start-frontend.ps1"
|
||||||
|
$backendScript = Join-Path $repoRoot "start-backend.ps1"
|
||||||
|
$mcpScript = Join-Path $repoRoot "start-mcp.ps1"
|
||||||
|
|
||||||
|
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||||
|
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[restart] Stopping target services first..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
if ($FrontendOnly) {
|
||||||
|
& $stopScript -FrontendOnly
|
||||||
|
}
|
||||||
|
elseif ($BackendOnly) {
|
||||||
|
& $stopScript -BackendOnly
|
||||||
|
}
|
||||||
|
elseif ($McpOnly) {
|
||||||
|
& $stopScript -McpOnly
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
& $stopScript -WithMcp:$WithMcp
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
|
||||||
|
if ($FrontendOnly) {
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
"-NoExit",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", $frontendScript
|
||||||
|
)
|
||||||
|
Write-Host "[restart] Frontend window restarted." -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($BackendOnly) {
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
"-NoExit",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", $backendScript,
|
||||||
|
"-DatabaseUrl", $DatabaseUrl
|
||||||
|
)
|
||||||
|
Write-Host "[restart] Backend window restarted." -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($McpOnly) {
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
"-NoExit",
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", $mcpScript,
|
||||||
|
"-ApiKey", $McpApiKey,
|
||||||
|
"-BackendApiBase", $BackendApiBase,
|
||||||
|
"-Port", $McpPort
|
||||||
|
)
|
||||||
|
Write-Host "[restart] MCP window restarted." -ForegroundColor Green
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
& $devScript -DatabaseUrl $DatabaseUrl -WithMcp:$WithMcp
|
||||||
41
start-mcp.ps1
Normal file
41
start-mcp.ps1
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
param(
|
||||||
|
[switch]$Install,
|
||||||
|
[string]$ApiKey = "termi-mcp-local-dev-key",
|
||||||
|
[string]$BackendApiBase = "http://127.0.0.1:5150/api",
|
||||||
|
[int]$Port = 5151
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$mcpDir = Join-Path $repoRoot "mcp-server"
|
||||||
|
|
||||||
|
if (-not (Test-Path $mcpDir)) {
|
||||||
|
throw "MCP server directory not found: $mcpDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location $mcpDir
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($Install -or -not (Test-Path (Join-Path $mcpDir "node_modules"))) {
|
||||||
|
Write-Host "[mcp] Installing dependencies..." -ForegroundColor Cyan
|
||||||
|
npm install
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "npm install failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:TERMI_MCP_API_KEY = $ApiKey
|
||||||
|
$env:TERMI_BACKEND_API_BASE = $BackendApiBase
|
||||||
|
$env:TERMI_MCP_PORT = "$Port"
|
||||||
|
|
||||||
|
Write-Host "[mcp] Backend API base set to $BackendApiBase" -ForegroundColor Cyan
|
||||||
|
Write-Host "[mcp] Starting MCP server on port $Port..." -ForegroundColor Green
|
||||||
|
npm run start
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "npm run start failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
106
stop-services.ps1
Normal file
106
stop-services.ps1
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
param(
|
||||||
|
[switch]$FrontendOnly,
|
||||||
|
[switch]$BackendOnly,
|
||||||
|
[switch]$McpOnly,
|
||||||
|
[switch]$WithMcp
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
|
||||||
|
if (@($FrontendOnly, $BackendOnly, $McpOnly).Where({ $_ }).Count -gt 1) {
|
||||||
|
throw "Use only one of -FrontendOnly, -BackendOnly, or -McpOnly."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-RepoShells {
|
||||||
|
param(
|
||||||
|
[string]$ScriptName
|
||||||
|
)
|
||||||
|
|
||||||
|
$shells = Get-CimInstance Win32_Process | Where-Object {
|
||||||
|
($_.Name -in @("powershell.exe", "pwsh.exe")) -and
|
||||||
|
$_.CommandLine -and
|
||||||
|
$_.CommandLine.Contains($repoRoot) -and
|
||||||
|
$_.CommandLine.Contains($ScriptName)
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($shell in $shells) {
|
||||||
|
try {
|
||||||
|
Stop-Process -Id $shell.ProcessId -Force -ErrorAction Stop
|
||||||
|
Write-Host "[stop] Closed shell for $ScriptName (PID $($shell.ProcessId))." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "[stop] Failed to close shell for $ScriptName (PID $($shell.ProcessId)): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-PortOwner {
|
||||||
|
param(
|
||||||
|
[int]$Port,
|
||||||
|
[string]$Label
|
||||||
|
)
|
||||||
|
|
||||||
|
$connections = Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -ExpandProperty OwningProcess -Unique
|
||||||
|
|
||||||
|
if (-not $connections) {
|
||||||
|
Write-Host "[stop] No process is listening on port $Port ($Label)." -ForegroundColor DarkGray
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($processId in $connections) {
|
||||||
|
if ($processId -le 0 -or $processId -eq $PID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Stop-Process -Id $processId -Force -ErrorAction Stop
|
||||||
|
Write-Host "[stop] Stopped $Label on port $Port (PID $processId)." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Warning "[stop] Failed to stop $Label on port $Port (PID $processId): $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-Frontend {
|
||||||
|
Stop-RepoShells -ScriptName "start-frontend.ps1"
|
||||||
|
Stop-PortOwner -Port 4321 -Label "frontend"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-Backend {
|
||||||
|
Stop-RepoShells -ScriptName "start-backend.ps1"
|
||||||
|
Stop-PortOwner -Port 5150 -Label "backend"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-Mcp {
|
||||||
|
Stop-RepoShells -ScriptName "start-mcp.ps1"
|
||||||
|
Stop-PortOwner -Port 5151 -Label "MCP"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($FrontendOnly) {
|
||||||
|
Stop-Frontend
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($BackendOnly) {
|
||||||
|
Stop-Backend
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($McpOnly) {
|
||||||
|
Stop-Mcp
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[stop] Stopping frontend and backend services..." -ForegroundColor Cyan
|
||||||
|
Stop-Frontend
|
||||||
|
Stop-Backend
|
||||||
|
|
||||||
|
if ($WithMcp) {
|
||||||
|
Stop-Mcp
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[stop] Requested services have been stopped." -ForegroundColor Green
|
||||||
Reference in New Issue
Block a user