Compare commits
13 Commits
12c249596a
...
beee62832f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beee62832f | ||
|
|
d89d02e81b | ||
|
|
94825316dc | ||
|
|
261989fca3 | ||
|
|
6aca08b8ab | ||
|
|
59640c6f44 | ||
|
|
5776af18ca | ||
|
|
33ab7b058d | ||
|
|
fb28fdd056 | ||
|
|
e83512382f | ||
|
|
22b5eab769 | ||
|
|
ae601774df | ||
|
|
3773fdc780 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -5181,7 +5181,6 @@ dependencies = [
|
|||||||
"actix-web",
|
"actix-web",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"deadpool-redis",
|
|
||||||
"futures",
|
"futures",
|
||||||
"hostname",
|
"hostname",
|
||||||
"metrics",
|
"metrics",
|
||||||
@ -5191,7 +5190,6 @@ dependencies = [
|
|||||||
"opentelemetry-http",
|
"opentelemetry-http",
|
||||||
"opentelemetry-otlp",
|
"opentelemetry-otlp",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
"redis",
|
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|||||||
@ -1,199 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { getMetrics, exportMetricsCsv, type InstanceMetrics } from "@/lib/admin-rpc";
|
|
||||||
|
|
||||||
export default function MetricsPage() {
|
|
||||||
const [metrics, setMetrics] = useState<InstanceMetrics[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
const [instanceFilter, setInstanceFilter] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => { loadMetrics(); }, []);
|
|
||||||
|
|
||||||
async function loadMetrics() {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const data = await getMetrics(instanceFilter);
|
|
||||||
setMetrics(data);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "加载指标失败");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleExportCsv() {
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
const csv = await exportMetricsCsv(instanceFilter);
|
|
||||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = `metrics-${format(new Date(), "yyyy-MM-dd_HHmm")}.csv`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} catch (e) {
|
|
||||||
alert("导出失败: " + (e instanceof Error ? e.message : String(e)));
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by instance
|
|
||||||
const byInstance = metrics.reduce<Record<string, InstanceMetrics[]>>((acc, m) => {
|
|
||||||
if (!acc[m.instance_id]) acc[m.instance_id] = [];
|
|
||||||
acc[m.instance_id].push(m);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const allInstances = Object.keys(byInstance).sort();
|
|
||||||
const latestOf = (arr: InstanceMetrics[]) =>
|
|
||||||
arr.sort((a, b) => b.timestamp_secs - a.timestamp_secs)[0];
|
|
||||||
|
|
||||||
// Flatten all latest snapshots for a summary table
|
|
||||||
const latestSnapshots = allInstances.map((id) => ({
|
|
||||||
id,
|
|
||||||
latest: latestOf(byInstance[id]),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function metricVal(m: InstanceMetrics | undefined, prefix: string, key: string): string {
|
|
||||||
if (!m) return "—";
|
|
||||||
const v = prefix === "http" ? m.http[key] : m.room[key];
|
|
||||||
return v ?? "—";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="admin-content">
|
|
||||||
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">应用指标监控</h1>
|
|
||||||
<p className="page-subtitle">
|
|
||||||
共 {allInstances.length} 个实例
|
|
||||||
{metrics.length > 0 && (
|
|
||||||
<span style={{ marginLeft: "8px", fontSize: "12px", color: "#737373" }}>
|
|
||||||
最新数据: {metrics.length > 0
|
|
||||||
? format(new Date(latestSnapshots[0]?.latest.timestamp_secs * 1000), "yyyy-MM-dd HH:mm:ss")
|
|
||||||
: "—"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
style={{ width: "200px" }}
|
|
||||||
placeholder="过滤实例名称..."
|
|
||||||
value={instanceFilter}
|
|
||||||
onChange={(e) => { setInstanceFilter(e.target.value); }}
|
|
||||||
/>
|
|
||||||
<button className="btn btn-secondary" onClick={loadMetrics} disabled={loading}>
|
|
||||||
{loading ? "加载中..." : "刷新"}
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-primary" onClick={handleExportCsv} disabled={exporting}>
|
|
||||||
{exporting ? "导出中..." : "导出 CSV"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="alert alert-error" style={{ marginBottom: "16px" }}>
|
|
||||||
{error} — 确认 adminrpc 服务正在运行(默认: http://adminrpc.admin.svc.cluster.local:9091)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div className="loading">加载中...</div>
|
|
||||||
) : allInstances.length === 0 ? (
|
|
||||||
<div className="card">
|
|
||||||
<div className="empty-state">
|
|
||||||
<div className="empty-state-icon">◉</div>
|
|
||||||
<p>暂无指标数据</p>
|
|
||||||
<p style={{ fontSize: "13px", color: "#737373" }}>
|
|
||||||
确认应用已将指标刷新到 Redis(每 5 秒一次)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Summary table */}
|
|
||||||
<div className="card" style={{ marginBottom: "16px" }}>
|
|
||||||
<h3 style={{ margin: "0 0 12px 0", fontSize: "14px", fontWeight: 600 }}>实例概览</h3>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>实例 ID</th>
|
|
||||||
<th>最后更新</th>
|
|
||||||
<th>活跃 WS 连接</th>
|
|
||||||
<th>发送消息数</th>
|
|
||||||
<th>HTTP 请求数</th>
|
|
||||||
<th>HTTP 错误数</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{latestSnapshots.map(({ id, latest }) => (
|
|
||||||
<tr key={id}>
|
|
||||||
<td><code style={{ fontSize: "12px" }}>{id}</code></td>
|
|
||||||
<td>{format(new Date(latest.timestamp_secs * 1000), "HH:mm:ss")}</td>
|
|
||||||
<td>{metricVal(latest, "room", "ws_connections_active")}</td>
|
|
||||||
<td>{metricVal(latest, "room", "messages_sent_total")}</td>
|
|
||||||
<td>{metricVal(latest, "http", "request_count")}</td>
|
|
||||||
<td>{metricVal(latest, "http", "error_count")}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Per-instance detail */}
|
|
||||||
{allInstances.map((instanceId) => (
|
|
||||||
<div key={instanceId} className="card" style={{ marginBottom: "16px" }}>
|
|
||||||
<h3 style={{ margin: "0 0 12px 0", fontSize: "14px", fontWeight: 600 }}>
|
|
||||||
实例: <code>{instanceId}</code>
|
|
||||||
</h3>
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}>
|
|
||||||
{/* Room metrics */}
|
|
||||||
<div>
|
|
||||||
<h4 style={{ fontSize: "13px", color: "#737373", margin: "0 0 8px 0" }}>Room 指标</h4>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px", fontSize: "13px" }}>
|
|
||||||
{Object.entries(latestOf(byInstance[instanceId])?.room ?? {}).map(([k, v]) => (
|
|
||||||
<div key={k} style={{ display: "flex", justifyContent: "space-between", padding: "2px 0", borderBottom: "1px solid #f4f4f5" }}>
|
|
||||||
<span style={{ color: "#525252" }}>{k}</span>
|
|
||||||
<code style={{ fontSize: "12px", color: "#171717" }}>{v}</code>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{Object.keys(latestOf(byInstance[instanceId])?.room ?? {}).length === 0 && (
|
|
||||||
<div style={{ color: "#a3a3a3", fontSize: "12px" }}>无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* HTTP metrics */}
|
|
||||||
<div>
|
|
||||||
<h4 style={{ fontSize: "13px", color: "#737373", margin: "0 0 8px 0" }}>HTTP 指标</h4>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "4px", fontSize: "13px" }}>
|
|
||||||
{Object.entries(latestOf(byInstance[instanceId])?.http ?? {}).map(([k, v]) => (
|
|
||||||
<div key={k} style={{ display: "flex", justifyContent: "space-between", padding: "2px 0", borderBottom: "1px solid #f4f4f5" }}>
|
|
||||||
<span style={{ color: "#525252" }}>{k}</span>
|
|
||||||
<code style={{ fontSize: "12px", color: "#171717" }}>{v}</code>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{Object.keys(latestOf(byInstance[instanceId])?.http ?? {}).length === 0 && (
|
|
||||||
<div style={{ color: "#a3a3a3", fontSize: "12px" }}>无数据</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { createModel, updateModel, deleteModel } from "@/lib/adminrpc/client";
|
import { createModel, updateModel, deleteModel } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
@ -11,7 +12,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Create model error:", msg);
|
logError("Create model error:", e);
|
||||||
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -27,7 +28,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Update model error:", msg);
|
logError("Update model error:", e);
|
||||||
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -42,7 +43,7 @@ export async function DELETE(req: NextRequest) {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Delete model error:", msg);
|
logError("Delete model error:", e);
|
||||||
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { updatePricing } from "@/lib/adminrpc/client";
|
import { updatePricing } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
@ -15,7 +16,7 @@ export async function PATCH(
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Update pricing error:", msg);
|
logError("Update pricing error:", e);
|
||||||
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { createProvider, updateProvider, deleteProvider } from "@/lib/adminrpc/client";
|
import { createProvider, updateProvider, deleteProvider } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
@ -11,7 +12,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Create provider error:", msg);
|
logError("Create provider error:", e);
|
||||||
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -27,7 +28,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Update provider error:", msg);
|
logError("Update provider error:", e);
|
||||||
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -42,7 +43,7 @@ export async function DELETE(req: NextRequest) {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Delete provider error:", msg);
|
logError("Delete provider error:", e);
|
||||||
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { createVersion, updateVersion, deleteVersion } from "@/lib/adminrpc/client";
|
import { createVersion, updateVersion, deleteVersion } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
@ -11,7 +12,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Create version error:", msg);
|
logError("Create version error:", e);
|
||||||
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `创建失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -27,7 +28,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Update version error:", msg);
|
logError("Update version error:", e);
|
||||||
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `更新失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -42,7 +43,7 @@ export async function DELETE(req: NextRequest) {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Delete version error:", msg);
|
logError("Delete version error:", e);
|
||||||
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `删除失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -45,7 +46,7 @@ export async function GET() {
|
|||||||
|
|
||||||
return NextResponse.json({ config });
|
return NextResponse.json({ config });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Get AI config error:", e);
|
logError("Get AI config error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -95,7 +96,7 @@ export async function PUT(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Update AI config error:", e);
|
logError("Update AI config error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -149,7 +150,7 @@ export async function POST(req: NextRequest) {
|
|||||||
aiSummaryUsed: !!aiSummary,
|
aiSummaryUsed: !!aiSummary,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[daily-report] Error:", e);
|
logError("[daily-report] Error:", e);
|
||||||
return NextResponse.json({ error: String(e) }, { status: 500 });
|
return NextResponse.json({ error: String(e) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -484,7 +485,7 @@ async function sendEmail(opts: {
|
|||||||
sentCount++;
|
sentCount++;
|
||||||
console.log(`[daily-report] Sent to ${recipient}`);
|
console.log(`[daily-report] Sent to ${recipient}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[daily-report] Failed to send to ${recipient}:`, e);
|
logError(`[daily-report] Failed to send to ${recipient}:`, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -66,7 +67,7 @@ export async function PATCH(
|
|||||||
if (err?.code === "23505") {
|
if (err?.code === "23505") {
|
||||||
return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 });
|
return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 });
|
||||||
}
|
}
|
||||||
console.error("Update recipient error:", e);
|
logError("Update recipient error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -99,7 +100,7 @@ export async function DELETE(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Delete recipient error:", e);
|
logError("Delete recipient error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -14,7 +15,7 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
return NextResponse.json({ recipients: result.rows });
|
return NextResponse.json({ recipients: result.rows });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[recipients] List error:", e);
|
logError("[recipients] List error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -53,7 +54,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if (err?.code === "23505") {
|
if (err?.code === "23505") {
|
||||||
return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 });
|
return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 });
|
||||||
}
|
}
|
||||||
console.error("[recipients] Add error:", e);
|
logError("[recipients] Add error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -38,7 +39,7 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
return NextResponse.json({ recipients: result.rows });
|
return NextResponse.json({ recipients: result.rows });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("List recipients error:", e);
|
logError("List recipients error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -78,7 +79,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if (err?.code === "23505") {
|
if (err?.code === "23505") {
|
||||||
return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 });
|
return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 });
|
||||||
}
|
}
|
||||||
console.error("Add recipient error:", e);
|
logError("Add recipient error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query, transaction } from "@/lib/db";
|
import { query, transaction } from "@/lib/db";
|
||||||
|
|
||||||
@ -41,7 +42,7 @@ export async function GET(
|
|||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Project billing error:", e);
|
logError("Project billing error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -82,7 +83,7 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({ ok: true, amount });
|
return NextResponse.json({ ok: true, amount });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Project add credit error:", e);
|
logError("Project add credit error:", e);
|
||||||
return NextResponse.json({ error: "充值失败" }, { status: 500 });
|
return NextResponse.json({ error: "充值失败" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -48,7 +49,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Update project member error:", e);
|
logError("Update project member error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,7 +91,7 @@ export async function DELETE(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Delete project member error:", e);
|
logError("Delete project member error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -38,7 +39,7 @@ export async function GET(
|
|||||||
|
|
||||||
return NextResponse.json({ members });
|
return NextResponse.json({ members });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("List project members error:", e);
|
logError("List project members error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,7 +109,7 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true, id: result.rows[0]?.id }, { status: 201 });
|
return NextResponse.json({ success: true, id: result.rows[0]?.id }, { status: 201 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Add project member error:", e);
|
logError("Add project member error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -56,7 +57,7 @@ export async function GET(
|
|||||||
billingHistory: billingHistoryRows.rows,
|
billingHistory: billingHistoryRows.rows,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Project detail error:", e);
|
logError("Project detail error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -64,7 +65,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ projects: projects.rows, total, page, pageSize });
|
return NextResponse.json({ projects: projects.rows, total, page, pageSize });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("List projects error:", e);
|
logError("List projects error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -108,7 +109,7 @@ export async function GET(
|
|||||||
|
|
||||||
return NextResponse.json({ repo, branches, commits });
|
return NextResponse.json({ repo, branches, commits });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Repo detail error:", e);
|
logError("Repo detail error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { deleteApiToken } from "@/lib/api-token";
|
import { deleteApiToken } from "@/lib/api-token";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -29,7 +30,7 @@ export async function DELETE(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Delete token error:", e);
|
logError("Delete token error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
listApiTokens,
|
listApiTokens,
|
||||||
@ -11,7 +12,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const tokens = await listApiTokens();
|
const tokens = await listApiTokens();
|
||||||
return NextResponse.json({ tokens });
|
return NextResponse.json({ tokens });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("List tokens error:", e);
|
logError("List tokens error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -58,7 +59,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ id: result.id, token, name: name.trim(), expiresAt }, { status: 201 });
|
return NextResponse.json({ id: result.id, token, name: name.trim(), expiresAt }, { status: 201 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Create token error:", e);
|
logError("Create token error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { login, buildSetCookieHeader } from "@/lib/auth";
|
import { login, buildSetCookieHeader } from "@/lib/auth";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -70,7 +71,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Login error:", e);
|
logError("Login error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { logout, parseSessionCookie, loadAdminSession, buildClearCookieHeader } from "@/lib/auth";
|
import { logout, parseSessionCookie, loadAdminSession, buildClearCookieHeader } from "@/lib/auth";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -31,7 +32,7 @@ export async function POST(req: NextRequest) {
|
|||||||
response.headers.set("Set-Cookie", buildClearCookieHeader());
|
response.headers.set("Set-Cookie", buildClearCookieHeader());
|
||||||
return response;
|
return response;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Logout error:", e);
|
logError("Logout error:", e);
|
||||||
const response = NextResponse.json({ success: false });
|
const response = NextResponse.json({ success: false });
|
||||||
response.headers.set("Set-Cookie", buildClearCookieHeader());
|
response.headers.set("Set-Cookie", buildClearCookieHeader());
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { parseSessionCookie, loadAdminSession, touchSession } from "@/lib/auth";
|
import { parseSessionCookie, loadAdminSession, touchSession } from "@/lib/auth";
|
||||||
|
|
||||||
@ -29,7 +30,7 @@ export async function GET(req: NextRequest) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Session check error:", e);
|
logError("Session check error:", e);
|
||||||
return NextResponse.json({ user: null });
|
return NextResponse.json({ user: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -132,7 +133,7 @@ export async function GET() {
|
|||||||
await ensureTables();
|
await ensureTables();
|
||||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Health] DB check failed:", e);
|
logError("[Health] DB check failed:", e);
|
||||||
return NextResponse.json({ status: "error", reason: String(e) }, { status: 503 });
|
return NextResponse.json({ status: "error", reason: String(e) }, { status: 503 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { listAuditLogs } from "@/lib/log";
|
import { listAuditLogs } from "@/lib/log";
|
||||||
|
|
||||||
@ -87,7 +88,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("List audit logs error:", e);
|
logError("List audit logs error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
listPermissions,
|
listPermissions,
|
||||||
@ -19,7 +20,7 @@ export async function GET() {
|
|||||||
const permissions = await listPermissions();
|
const permissions = await listPermissions();
|
||||||
return NextResponse.json({ permissions });
|
return NextResponse.json({ permissions });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("List permissions error:", e);
|
logError("List permissions error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -63,7 +64,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if ((e as { code?: string }).code === "23505") {
|
if ((e as { code?: string }).code === "23505") {
|
||||||
return NextResponse.json({ error: "权限代码已存在" }, { status: 409 });
|
return NextResponse.json({ error: "权限代码已存在" }, { status: 409 });
|
||||||
}
|
}
|
||||||
console.error("Create permission error:", e);
|
logError("Create permission error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -97,7 +98,7 @@ export async function PUT(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json(permission);
|
return NextResponse.json(permission);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Update permission error:", e);
|
logError("Update permission error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -125,7 +126,7 @@ export async function DELETE(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Delete permission error:", e);
|
logError("Delete permission error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -76,7 +77,7 @@ export async function GET() {
|
|||||||
last24h: parseInt(last24h.rows[0]?.count || "0", 10),
|
last24h: parseInt(last24h.rows[0]?.count || "0", 10),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Activity stats error:", e);
|
logError("Activity stats error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -97,7 +98,7 @@ export async function GET(req: NextRequest) {
|
|||||||
versions: versionsList,
|
versions: versionsList,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("AI data error:", e);
|
logError("AI data error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { syncModels } from "@/lib/adminrpc/client";
|
import { syncModels } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
@ -12,7 +13,7 @@ export async function POST() {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("AI sync error:", msg);
|
logError("AI sync error:", e);
|
||||||
return NextResponse.json({ error: `同步失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `同步失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { checkAlerts } from "@/lib/adminrpc/client";
|
import { checkAlerts } from "@/lib/adminrpc/client";
|
||||||
|
|
||||||
@ -12,7 +13,7 @@ export async function POST() {
|
|||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
console.error("Alert check error:", msg);
|
logError("Alert check error:", e);
|
||||||
return NextResponse.json({ error: `检查失败: ${msg}` }, { status: 500 });
|
return NextResponse.json({ error: `检查失败: ${msg}` }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -99,7 +100,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ logs: combined.slice(0, pageSize), total, page, pageSize });
|
return NextResponse.json({ logs: combined.slice(0, pageSize), total, page, pageSize });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Platform audit logs error:", e);
|
logError("Platform audit logs error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -90,7 +91,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ repos, total, page, pageSize });
|
return NextResponse.json({ repos, total, page, pageSize });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Repos error:", e);
|
logError("Repos error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { getAdminUserId } from "@/lib/auth";
|
import { getAdminUserId } from "@/lib/auth";
|
||||||
@ -30,7 +31,7 @@ export async function DELETE(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Revoke message error:", e);
|
logError("Revoke message error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -72,7 +73,7 @@ export async function GET(
|
|||||||
|
|
||||||
return NextResponse.json({ messages, total, page, pageSize });
|
return NextResponse.json({ messages, total, page, pageSize });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Room messages error:", e);
|
logError("Room messages error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -90,7 +91,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ rooms, total, page, pageSize });
|
return NextResponse.json({ rooms, total, page, pageSize });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Rooms error:", e);
|
logError("Rooms error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
getOnlinePlatformSessions,
|
getOnlinePlatformSessions,
|
||||||
@ -22,7 +23,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
return NextResponse.json({ sessions });
|
return NextResponse.json({ sessions });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Get platform sessions error:", e);
|
logError("Get platform sessions error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -51,7 +52,7 @@ export async function DELETE(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: ok });
|
return NextResponse.json({ success: ok });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Delete platform session error:", e);
|
logError("Delete platform session error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -73,7 +74,7 @@ export async function GET() {
|
|||||||
planDistribution,
|
planDistribution,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Stats error:", e);
|
logError("Stats error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -45,7 +46,7 @@ export async function GET(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Get user error:", e);
|
logError("Get user error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -145,7 +146,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Update user error:", e);
|
logError("Update user error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -55,7 +56,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ users, total, page, pageSize });
|
return NextResponse.json({ users, total, page, pageSize });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Platform users error:", e);
|
logError("Platform users error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -110,7 +111,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: true, updated: uids.length });
|
return NextResponse.json({ success: true, updated: uids.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Batch update user status error:", e);
|
logError("Batch update user status error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query, getClient } from "@/lib/db";
|
import { query, getClient } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -94,7 +95,7 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true, amount, currency });
|
return NextResponse.json({ success: true, amount, currency });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Add workspace credit error:", e);
|
logError("Add workspace credit error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query, getClient } from "@/lib/db";
|
import { query, getClient } from "@/lib/db";
|
||||||
import { getAdminUserId } from "@/lib/auth";
|
import { getAdminUserId } from "@/lib/auth";
|
||||||
@ -35,7 +36,7 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
return NextResponse.json({ configs: result.rows });
|
return NextResponse.json({ configs: result.rows });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Alert config error:", e);
|
logError("Alert config error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -87,7 +88,7 @@ export async function PUT(
|
|||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Alert config update error:", e);
|
logError("Alert config update error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -52,7 +53,7 @@ export async function PATCH(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Update workspace member error:", e);
|
logError("Update workspace member error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -94,7 +95,7 @@ export async function DELETE(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Delete workspace member error:", e);
|
logError("Delete workspace member error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -43,7 +44,7 @@ export async function GET(
|
|||||||
|
|
||||||
return NextResponse.json({ members });
|
return NextResponse.json({ members });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("List workspace members error:", e);
|
logError("List workspace members error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -113,7 +114,7 @@ export async function POST(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true, id: result.rows[0]?.id }, { status: 201 });
|
return NextResponse.json({ success: true, id: result.rows[0]?.id }, { status: 201 });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Add workspace member error:", e);
|
logError("Add workspace member error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
|
|
||||||
@ -59,7 +60,7 @@ export async function GET(
|
|||||||
billingHistory: billing.rows,
|
billingHistory: billing.rows,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Workspace detail error:", e);
|
logError("Workspace detail error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { query } from "@/lib/db";
|
import { query } from "@/lib/db";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -61,7 +62,7 @@ export async function GET(req: NextRequest) {
|
|||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("List workspaces error:", e);
|
logError("List workspaces error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,7 +109,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: true, updated: ids.length });
|
return NextResponse.json({ success: true, updated: ids.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Batch update workspace plan error:", e);
|
logError("Batch update workspace plan error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
getRoleById,
|
getRoleById,
|
||||||
@ -27,7 +28,7 @@ export async function GET(
|
|||||||
const permissions = await getRolePermissions(roleId);
|
const permissions = await getRolePermissions(roleId);
|
||||||
return NextResponse.json({ ...role, permissions });
|
return NextResponse.json({ ...role, permissions });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Get role error:", e);
|
logError("Get role error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -66,7 +67,7 @@ export async function PUT(
|
|||||||
|
|
||||||
return NextResponse.json(role);
|
return NextResponse.json(role);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Update role error:", e);
|
logError("Update role error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -94,7 +95,7 @@ export async function DELETE(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Delete role error:", e);
|
logError("Delete role error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
listRoles,
|
listRoles,
|
||||||
@ -19,7 +20,7 @@ export async function GET() {
|
|||||||
const roles = await listRoles();
|
const roles = await listRoles();
|
||||||
return NextResponse.json({ roles });
|
return NextResponse.json({ roles });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("List roles error:", e);
|
logError("List roles error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -60,7 +61,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if ((e as { code?: string }).code === "23505") {
|
if ((e as { code?: string }).code === "23505") {
|
||||||
return NextResponse.json({ error: "角色名已存在" }, { status: 409 });
|
return NextResponse.json({ error: "角色名已存在" }, { status: 409 });
|
||||||
}
|
}
|
||||||
console.error("Create role error:", e);
|
logError("Create role error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getOnlineSessions } from "@/lib/redis";
|
import { getOnlineSessions } from "@/lib/redis";
|
||||||
import { createAuditLog } from "@/lib/log";
|
import { createAuditLog } from "@/lib/log";
|
||||||
@ -14,7 +15,7 @@ export async function GET() {
|
|||||||
const sessions = await getOnlineSessions();
|
const sessions = await getOnlineSessions();
|
||||||
return NextResponse.json({ sessions });
|
return NextResponse.json({ sessions });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Get sessions error:", e);
|
logError("Get sessions error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -44,7 +45,7 @@ export async function DELETE(req: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: ok });
|
return NextResponse.json({ success: ok });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Delete session error:", e);
|
logError("Delete session error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
getUserById,
|
getUserById,
|
||||||
@ -32,7 +33,7 @@ export async function GET(
|
|||||||
|
|
||||||
return NextResponse.json({ ...safeUser, roles });
|
return NextResponse.json({ ...safeUser, roles });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Get user error:", e);
|
logError("Get user error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,7 +81,7 @@ export async function PUT(
|
|||||||
const { password_hash: _, ...safeUser } = user;
|
const { password_hash: _, ...safeUser } = user;
|
||||||
return NextResponse.json(safeUser);
|
return NextResponse.json(safeUser);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Update user error:", e);
|
logError("Update user error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -113,7 +114,7 @@ export async function DELETE(
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Delete user error:", e);
|
logError("Delete user error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { logError } from "@/lib/logger";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import {
|
import {
|
||||||
listUsers,
|
listUsers,
|
||||||
@ -24,7 +25,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const result = await listUsers({ page, pageSize, search });
|
const result = await listUsers({ page, pageSize, search });
|
||||||
return NextResponse.json(result);
|
return NextResponse.json(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("List users error:", e);
|
logError("List users error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -70,7 +71,7 @@ export async function POST(req: NextRequest) {
|
|||||||
if ((e as { code?: string }).code === "23505") {
|
if ((e as { code?: string }).code === "23505") {
|
||||||
return NextResponse.json({ error: "用户名已存在" }, { status: 409 });
|
return NextResponse.json({ error: "用户名已存在" }, { status: 409 });
|
||||||
}
|
}
|
||||||
console.error("Create user error:", e);
|
logError("Create user error:", e);
|
||||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,7 +52,6 @@ export default function Sidebar({ user, loading, onLogout }: SidebarProps) {
|
|||||||
{ href: "/admin/sessions", label: "Admin 会话", icon: "◐" },
|
{ href: "/admin/sessions", label: "Admin 会话", icon: "◐" },
|
||||||
{ href: "/platform/sessions", label: "平台会话", icon: "☀" },
|
{ href: "/platform/sessions", label: "平台会话", icon: "☀" },
|
||||||
{ href: "/admin/api-tokens", label: "API Token", icon: "⚿" },
|
{ href: "/admin/api-tokens", label: "API Token", icon: "⚿" },
|
||||||
{ href: "/admin/metrics", label: "指标监控", icon: "◉" },
|
|
||||||
{ href: "/admin/daily-report", label: "每日报告", icon: "📧" },
|
{ href: "/admin/daily-report", label: "每日报告", icon: "📧" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* adminrpc HTTP REST client
|
* adminrpc HTTP REST client
|
||||||
*
|
*
|
||||||
* Calls the adminrpc HTTP server (default: http://gitdata-adminrpc.gitdataai.svc.cluster.local:9091)
|
* Calls the adminrpc HTTP server (default: http://gitdata-adminrpc.gitdataai.svc.cluster.local:9091)
|
||||||
* which exposes the same session management and metrics APIs as the gRPC service.
|
* which exposes session management and health APIs.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* import { listWorkspaceSessions, kickUser } from "@/lib/admin-rpc";
|
* import { listWorkspaceSessions, kickUser } from "@/lib/admin-rpc";
|
||||||
@ -99,31 +99,6 @@ export async function kickUserFromWorkspace(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Metrics API ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface InstanceMetrics {
|
|
||||||
instance_id: string;
|
|
||||||
timestamp_secs: number;
|
|
||||||
http: Record<string, string>;
|
|
||||||
room: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get metrics across all app instances. */
|
|
||||||
export async function getMetrics(instanceFilter = ""): Promise<InstanceMetrics[]> {
|
|
||||||
const qs = instanceFilter ? `?instance_filter=${encodeURIComponent(instanceFilter)}` : "";
|
|
||||||
return rpc<InstanceMetrics[]>(`/api/admin/metrics${qs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Export all metrics as CSV string. */
|
|
||||||
export async function exportMetricsCsv(instanceFilter = ""): Promise<string> {
|
|
||||||
const qs = instanceFilter ? `?instance_filter=${encodeURIComponent(instanceFilter)}` : "";
|
|
||||||
const res = await fetch(`${BASE_URL}/api/admin/metrics/export${qs}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`adminrpc GET /metrics/export failed (${res.status})`);
|
|
||||||
}
|
|
||||||
return res.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get adminrpc health status. */
|
/** Get adminrpc health status. */
|
||||||
export async function adminRpcHealth(): Promise<{ ok: boolean }> {
|
export async function adminRpcHealth(): Promise<{ ok: boolean }> {
|
||||||
return rpc<{ ok: boolean }>("/health");
|
return rpc<{ ok: boolean }>("/health");
|
||||||
|
|||||||
70
admin/src/lib/logger.ts
Normal file
70
admin/src/lib/logger.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Structured error logger for the admin module.
|
||||||
|
*
|
||||||
|
* All API route errors should use this instead of bare console.error.
|
||||||
|
* Prints: [timestamp] [ERROR] context | message | stack | extra
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LogExtra {
|
||||||
|
method?: string;
|
||||||
|
url?: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
body?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format and print a detailed error to stderr.
|
||||||
|
* @param context Short label describing where the error occurred (e.g. "GET /api/users")
|
||||||
|
* @param error The caught error (any type)
|
||||||
|
* @param extra Optional additional context (request method, url, params, etc.)
|
||||||
|
*/
|
||||||
|
export function logError(context: string, error: unknown, extra?: LogExtra): void {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// Extract best-effort message
|
||||||
|
let message = "unknown error";
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
} else if (typeof error === "string") {
|
||||||
|
message = error;
|
||||||
|
} else if (error && typeof error === "object" && "message" in error) {
|
||||||
|
message = String((error as { message: unknown }).message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract stack trace
|
||||||
|
let stack = "";
|
||||||
|
if (error instanceof Error && error.stack) {
|
||||||
|
stack = error.stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraStr = extra
|
||||||
|
? ` | extra: ${JSON.stringify(redact(extra))}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Multi-line format for easy grepping
|
||||||
|
console.error(
|
||||||
|
`[${timestamp}] [ERROR] ${context}` +
|
||||||
|
`\n message: ${message}` +
|
||||||
|
(stack ? `\n stack: ${stack}` : "") +
|
||||||
|
extraStr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively remove sensitive fields before logging */
|
||||||
|
function redact(obj: unknown, depth = 0): unknown {
|
||||||
|
if (depth > 4) return "[max-depth]";
|
||||||
|
if (obj === null || obj === undefined) return obj;
|
||||||
|
if (typeof obj !== "object") return obj;
|
||||||
|
if (Array.isArray(obj)) return obj.map((v) => redact(v, depth + 1));
|
||||||
|
const sensitive = ["password", "token", "secret", "key", "authorization", "cookie", "api_key"];
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
if (sensitive.some((s) => k.toLowerCase().includes(s))) {
|
||||||
|
result[k] = "[REDACTED]";
|
||||||
|
} else {
|
||||||
|
result[k] = redact(v, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@ -78,15 +78,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let http_handle = tokio::spawn(async move {
|
let http_handle = tokio::spawn(async move {
|
||||||
let pool_for_http = pool.clone();
|
|
||||||
let sm_for_http = session_manager.clone();
|
let sm_for_http = session_manager.clone();
|
||||||
|
|
||||||
let result = HttpServer::new(move || {
|
let result = HttpServer::new(move || {
|
||||||
ActixApp::new()
|
ActixApp::new()
|
||||||
.app_data(web::Data::new(pool_for_http.clone()))
|
|
||||||
.app_data(web::Data::new(sm_for_http.clone()))
|
.app_data(web::Data::new(sm_for_http.clone()))
|
||||||
.route("/health", web::get().to(health))
|
.route("/health", web::get().to(health))
|
||||||
.route("/admin/metrics/export", web::get().to(metrics_export))
|
|
||||||
.service(
|
.service(
|
||||||
web::scope("/api/admin")
|
web::scope("/api/admin")
|
||||||
.route(
|
.route(
|
||||||
@ -117,10 +114,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.route(
|
.route(
|
||||||
"/sessions/kick-workspace",
|
"/sessions/kick-workspace",
|
||||||
web::post().to(kick_user_from_workspace),
|
web::post().to(kick_user_from_workspace),
|
||||||
)
|
),
|
||||||
// Metrics
|
|
||||||
.route("/metrics", web::get().to(get_metrics))
|
|
||||||
.route("/metrics/export", web::get().to(metrics_export)),
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.bind(admin_addr)
|
.bind(admin_addr)
|
||||||
@ -149,26 +143,6 @@ async fn health() -> HttpResponse {
|
|||||||
HttpResponse::Ok().json(serde_json::json!({ "ok": true }))
|
HttpResponse::Ok().json(serde_json::json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn metrics_export(pool: web::Data<cluster::Pool>) -> HttpResponse {
|
|
||||||
match observability::export_all_metrics_csv(pool.get_ref(), "").await {
|
|
||||||
Ok(csv) => HttpResponse::Ok()
|
|
||||||
.content_type("text/csv; charset=utf-8")
|
|
||||||
.body(csv),
|
|
||||||
Err(e) => {
|
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_metrics(pool: web::Data<cluster::Pool>) -> HttpResponse {
|
|
||||||
match observability::query_all_instance_metrics(pool.get_ref(), "", 100).await {
|
|
||||||
Ok(instances) => HttpResponse::Ok().json(instances),
|
|
||||||
Err(e) => {
|
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": e.to_string() }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_uuid(s: &str) -> Option<Uuid> {
|
fn parse_uuid(s: &str) -> Option<Uuid> {
|
||||||
Uuid::parse_str(s).ok()
|
Uuid::parse_str(s).ok()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,6 @@ use db::cache::AppCache;
|
|||||||
use db::database::AppDatabase;
|
use db::database::AppDatabase;
|
||||||
use observability::{
|
use observability::{
|
||||||
init_tracing_subscriber, install_recorder, prometheus_handler, spawn_http_metrics_poller,
|
init_tracing_subscriber, install_recorder, prometheus_handler, spawn_http_metrics_poller,
|
||||||
spawn_redis_metrics_flusher,
|
|
||||||
HttpMetrics, HttpSnapshotGuard, MetricsMiddleware, TracingSpanMiddleware, instance_id,
|
HttpMetrics, HttpSnapshotGuard, MetricsMiddleware, TracingSpanMiddleware, instance_id,
|
||||||
};
|
};
|
||||||
use sea_orm::ConnectionTrait;
|
use sea_orm::ConnectionTrait;
|
||||||
@ -102,18 +101,6 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
let http_snapshot_data = web::Data::new(http_snapshot);
|
let http_snapshot_data = web::Data::new(http_snapshot);
|
||||||
|
|
||||||
// ── Redis metrics flusher (every 5s → key: metrics:{instance}:{ts}) ──────
|
|
||||||
let http_for_flusher = http_metrics.clone();
|
|
||||||
let prometheus_for_flusher = prometheus_handle_arc.clone();
|
|
||||||
spawn_redis_metrics_flusher(
|
|
||||||
cache.redis_pool().clone(),
|
|
||||||
instance_id(),
|
|
||||||
prometheus_for_flusher,
|
|
||||||
http_for_flusher,
|
|
||||||
std::time::Duration::from_secs(5),
|
|
||||||
);
|
|
||||||
tracing::info!("Redis metrics flusher started (5s interval)");
|
|
||||||
|
|
||||||
let bind_addr = args.bind.unwrap_or_else(|| "127.0.0.1:8080".to_string());
|
let bind_addr = args.bind.unwrap_or_else(|| "127.0.0.1:8080".to_string());
|
||||||
tracing::info!(bind_addr = %bind_addr, "Listening");
|
tracing::info!(bind_addr = %bind_addr, "Listening");
|
||||||
let http_metrics_server = http_metrics.clone();
|
let http_metrics_server = http_metrics.clone();
|
||||||
|
|||||||
@ -5,7 +5,9 @@ use async_openai::Client;
|
|||||||
use async_openai::types::chat::{
|
use async_openai::types::chat::{
|
||||||
ChatCompletionMessageToolCalls, ChatCompletionRequestAssistantMessage,
|
ChatCompletionMessageToolCalls, ChatCompletionRequestAssistantMessage,
|
||||||
ChatCompletionRequestAssistantMessageContent, ChatCompletionRequestMessage,
|
ChatCompletionRequestAssistantMessageContent, ChatCompletionRequestMessage,
|
||||||
ChatCompletionRequestSystemMessage, ChatCompletionRequestUserMessage, ChatCompletionTool,
|
ChatCompletionRequestSystemMessage, ChatCompletionRequestToolMessage,
|
||||||
|
ChatCompletionRequestToolMessageContent, ChatCompletionRequestUserMessage,
|
||||||
|
ChatCompletionRequestUserMessageContent, ChatCompletionTool,
|
||||||
ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse,
|
ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse,
|
||||||
CreateChatCompletionStreamResponse, FinishReason, ReasoningEffort, ToolChoiceOptions,
|
CreateChatCompletionStreamResponse, FinishReason, ReasoningEffort, ToolChoiceOptions,
|
||||||
};
|
};
|
||||||
@ -193,7 +195,24 @@ impl ChatService {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if !calls.is_empty() {
|
if !calls.is_empty() {
|
||||||
let tool_messages = self.execute_tool_calls(calls, &request).await?;
|
let tool_messages = match self.execute_tool_calls(calls, &request).await {
|
||||||
|
Ok(msgs) => msgs,
|
||||||
|
Err(e) => {
|
||||||
|
// Surface the error as a tool result so the model can continue
|
||||||
|
let err_text = format!("[Tool call failed: {}]", e);
|
||||||
|
messages.push(ChatCompletionRequestMessage::User(
|
||||||
|
ChatCompletionRequestUserMessage {
|
||||||
|
content: ChatCompletionRequestUserMessageContent::Text(err_text.clone()),
|
||||||
|
name: None,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
tool_depth += 1;
|
||||||
|
if tool_depth >= max_tool_depth {
|
||||||
|
return Ok(err_text);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
messages.extend(tool_messages);
|
messages.extend(tool_messages);
|
||||||
|
|
||||||
tool_depth += 1;
|
tool_depth += 1;
|
||||||
@ -387,7 +406,25 @@ impl ChatService {
|
|||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
let tool_messages = self.execute_tool_calls(tool_calls, &request).await?;
|
let tool_messages = match self.execute_tool_calls(tool_calls, &request).await {
|
||||||
|
Ok(msgs) => msgs,
|
||||||
|
Err(e) => {
|
||||||
|
// Stream the FC error as an observation so the user sees it
|
||||||
|
let err_text = format!("[Tool call failed: {}]", e);
|
||||||
|
on_chunk(AiStreamChunk {
|
||||||
|
content: err_text.clone(),
|
||||||
|
done: true,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
// Return an empty tool result so the loop can continue
|
||||||
|
vec![ChatCompletionRequestMessage::Tool(
|
||||||
|
ChatCompletionRequestToolMessage {
|
||||||
|
tool_call_id: String::new(),
|
||||||
|
content: ChatCompletionRequestToolMessageContent::Text(err_text),
|
||||||
|
},
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
};
|
||||||
messages.extend(tool_messages);
|
messages.extend(tool_messages);
|
||||||
|
|
||||||
tool_depth += 1;
|
tool_depth += 1;
|
||||||
|
|||||||
@ -378,15 +378,21 @@ fn parse_react_response(content: &str) -> ParsedReActResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the first JSON object or array from a string, handling markdown fences.
|
/// Extract a JSON object or array from a string, even when wrapped in non-JSON text.
|
||||||
|
/// Handles: raw JSON at start, JSON in code fences, JSON buried in text (e.g. after
|
||||||
|
/// a prefix line like "[Agent ran through N steps]").
|
||||||
fn extract_json(s: &str) -> Option<String> {
|
fn extract_json(s: &str) -> Option<String> {
|
||||||
let trimmed = s.trim();
|
let trimmed = s.trim();
|
||||||
|
|
||||||
|
// Direct match — starts with { or [
|
||||||
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
if trimmed.starts_with('{') || trimmed.starts_with('[') {
|
||||||
return Some(trimmed.to_string());
|
return Some(trimmed.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Code fence handling
|
||||||
for line in trimmed.lines() {
|
for line in trimmed.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.starts_with("```json") || line.starts_with("```") {
|
if line.starts_with("```json") || line == "```" {
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
let mut found_start = false;
|
let mut found_start = false;
|
||||||
for l in trimmed.lines() {
|
for l in trimmed.lines() {
|
||||||
@ -409,6 +415,47 @@ fn extract_json(s: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scan for JSON object/array buried in text (common with prefix lines).
|
||||||
|
// Find the first '{' or '[' that is NOT preceded by a word character,
|
||||||
|
// then try to parse from there (stripping trailing non-JSON text).
|
||||||
|
let chars: Vec<char> = trimmed.chars().collect();
|
||||||
|
for i in 0..chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
if (c == '{' || c == '[') && i > 0 {
|
||||||
|
// Skip if preceded by a word character (would be part of a string value)
|
||||||
|
let prev = chars[i - 1];
|
||||||
|
if prev.is_alphanumeric() || prev == '_' || prev == '"' || prev == '\'' {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let candidate: String = chars[i..].iter().collect();
|
||||||
|
// Try full candidate first
|
||||||
|
if serde_json::from_str::<serde_json::Value>(&candidate).is_ok() {
|
||||||
|
return Some(candidate.trim_end().to_string());
|
||||||
|
}
|
||||||
|
// Try stripping trailing text (text after the JSON closing brace/bracket)
|
||||||
|
let mut depth = 0isize;
|
||||||
|
let mut in_string = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
for (j, c) in candidate.char_indices() {
|
||||||
|
if escaped { escaped = false; continue; }
|
||||||
|
if c == '\\' { escaped = true; continue; }
|
||||||
|
if c == '"' { in_string = !in_string; continue; }
|
||||||
|
if in_string { continue; }
|
||||||
|
if c == '{' || c == '[' { depth += 1; }
|
||||||
|
if c == '}' || c == ']' { depth -= 1; }
|
||||||
|
if depth == 0 {
|
||||||
|
// Found the end of the JSON value
|
||||||
|
let json_end = j + c.len_utf8();
|
||||||
|
let trimmed_candidate = &candidate[..json_end];
|
||||||
|
if serde_json::from_str::<serde_json::Value>(trimmed_candidate).is_ok() {
|
||||||
|
return Some(trimmed_candidate.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -728,6 +728,9 @@ impl WsRequestHandler {
|
|||||||
WsAction::UnsubscribeRoom => Ok(WsResponseData::bool(true)),
|
WsAction::UnsubscribeRoom => Ok(WsResponseData::bool(true)),
|
||||||
WsAction::SubscribeProject => Ok(WsResponseData::subscribed(None, None)),
|
WsAction::SubscribeProject => Ok(WsResponseData::subscribed(None, None)),
|
||||||
WsAction::UnsubscribeProject => Ok(WsResponseData::bool(true)),
|
WsAction::UnsubscribeProject => Ok(WsResponseData::bool(true)),
|
||||||
|
// TypingStart/TypingStop are handled directly in ws_universal.rs
|
||||||
|
// (interception point sends response there, so this arm is never reached)
|
||||||
|
WsAction::TypingStart | WsAction::TypingStop => Ok(WsResponseData::bool(true)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -114,6 +114,10 @@ pub enum WsAction {
|
|||||||
SubscribeProject,
|
SubscribeProject,
|
||||||
#[serde(rename = "project.unsubscribe")]
|
#[serde(rename = "project.unsubscribe")]
|
||||||
UnsubscribeProject,
|
UnsubscribeProject,
|
||||||
|
#[serde(rename = "typing.start")]
|
||||||
|
TypingStart,
|
||||||
|
#[serde(rename = "typing.stop")]
|
||||||
|
TypingStop,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for WsAction {
|
impl std::fmt::Display for WsAction {
|
||||||
@ -164,6 +168,8 @@ impl std::fmt::Display for WsAction {
|
|||||||
WsAction::UnsubscribeRoom => write!(f, "room.unsubscribe"),
|
WsAction::UnsubscribeRoom => write!(f, "room.unsubscribe"),
|
||||||
WsAction::SubscribeProject => write!(f, "project.subscribe"),
|
WsAction::SubscribeProject => write!(f, "project.subscribe"),
|
||||||
WsAction::UnsubscribeProject => write!(f, "project.unsubscribe"),
|
WsAction::UnsubscribeProject => write!(f, "project.unsubscribe"),
|
||||||
|
WsAction::TypingStart => write!(f, "typing.start"),
|
||||||
|
WsAction::TypingStop => write!(f, "typing.stop"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -211,6 +217,8 @@ pub struct WsRequestParams {
|
|||||||
pub min_score: Option<f32>,
|
pub min_score: Option<f32>,
|
||||||
pub query: Option<String>,
|
pub query: Option<String>,
|
||||||
pub attachment_ids: Option<Vec<Uuid>>,
|
pub attachment_ids: Option<Vec<Uuid>>,
|
||||||
|
/// Typing event: "start" or "stop"
|
||||||
|
pub typing: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
@ -9,7 +9,7 @@ use tokio_stream::wrappers::BroadcastStream;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use queue::{ReactionGroup, RoomMessageEvent, RoomMessageStreamChunkEvent};
|
use queue::{ReactionGroup, RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent};
|
||||||
use room::connection::RoomConnectionManager;
|
use room::connection::RoomConnectionManager;
|
||||||
use service::AppService;
|
use service::AppService;
|
||||||
|
|
||||||
@ -40,6 +40,10 @@ pub enum WsPushEvent {
|
|||||||
room_id: Uuid,
|
room_id: Uuid,
|
||||||
chunk: Arc<RoomMessageStreamChunkEvent>,
|
chunk: Arc<RoomMessageStreamChunkEvent>,
|
||||||
},
|
},
|
||||||
|
TypingIndicator {
|
||||||
|
room_id: Uuid,
|
||||||
|
event: Arc<TypingEvent>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps room_id -> (room_message_broadcast_stream, stream_chunk_broadcast_stream)
|
/// Maps room_id -> (room_message_broadcast_stream, stream_chunk_broadcast_stream)
|
||||||
@ -48,6 +52,7 @@ type PushStreams = HashMap<
|
|||||||
(
|
(
|
||||||
BroadcastStream<Arc<RoomMessageEvent>>,
|
BroadcastStream<Arc<RoomMessageEvent>>,
|
||||||
BroadcastStream<Arc<RoomMessageStreamChunkEvent>>,
|
BroadcastStream<Arc<RoomMessageStreamChunkEvent>>,
|
||||||
|
BroadcastStream<Arc<TypingEvent>>,
|
||||||
),
|
),
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@ -245,6 +250,22 @@ pub async fn ws_universal(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(WsPushEvent::TypingIndicator { room_id, event }) => {
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"type": "event",
|
||||||
|
"event": "room.typing",
|
||||||
|
"room_id": room_id,
|
||||||
|
"data": {
|
||||||
|
"user_id": event.user_id,
|
||||||
|
"username": event.username,
|
||||||
|
"avatar_url": event.avatar_url,
|
||||||
|
"action": event.action,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if session.text(payload.to_string()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
None => {
|
None => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -307,9 +328,11 @@ pub async fn ws_universal(
|
|||||||
match manager.subscribe(room_id, user_id).await {
|
match manager.subscribe(room_id, user_id).await {
|
||||||
Ok(rx) => {
|
Ok(rx) => {
|
||||||
let stream_rx = manager.subscribe_room_stream(room_id).await;
|
let stream_rx = manager.subscribe_room_stream(room_id).await;
|
||||||
|
let typing_rx = manager.subscribe_typing(room_id).await;
|
||||||
push_streams.insert(room_id, (
|
push_streams.insert(room_id, (
|
||||||
BroadcastStream::new(rx),
|
BroadcastStream::new(rx),
|
||||||
BroadcastStream::new(stream_rx),
|
BroadcastStream::new(stream_rx),
|
||||||
|
BroadcastStream::new(typing_rx),
|
||||||
));
|
));
|
||||||
let _ = session.text(serde_json::to_string(&WsResponse::success(
|
let _ = session.text(serde_json::to_string(&WsResponse::success(
|
||||||
request.request_id, &action_str,
|
request.request_id, &action_str,
|
||||||
@ -338,6 +361,24 @@ pub async fn ws_universal(
|
|||||||
request.request_id, &action_str, WsResponseData::bool(true)
|
request.request_id, &action_str, WsResponseData::bool(true)
|
||||||
)).unwrap_or_default()).await;
|
)).unwrap_or_default()).await;
|
||||||
}
|
}
|
||||||
|
WsAction::TypingStart | WsAction::TypingStop => {
|
||||||
|
if let (Some(room_id), Some(action)) =
|
||||||
|
(request.params().room_id, request.params().typing.as_deref())
|
||||||
|
{
|
||||||
|
let names = handler.service().room.get_user_names(&[user_id]).await;
|
||||||
|
let typing_event = TypingEvent {
|
||||||
|
room_id,
|
||||||
|
user_id,
|
||||||
|
username: names.into_values().next().unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
avatar_url: None,
|
||||||
|
action: action.to_string(),
|
||||||
|
};
|
||||||
|
manager.broadcast_typing(room_id, typing_event).await;
|
||||||
|
}
|
||||||
|
let _ = session.text(serde_json::to_string(&WsResponse::success(
|
||||||
|
request.request_id, &action_str, WsResponseData::bool(true)
|
||||||
|
)).unwrap_or_default()).await;
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let resp = handler.handle(request).await;
|
let resp = handler.handle(request).await;
|
||||||
let _ = session.text(serde_json::to_string(&resp).unwrap_or_default()).await;
|
let _ = session.text(serde_json::to_string(&resp).unwrap_or_default()).await;
|
||||||
@ -383,7 +424,7 @@ async fn poll_push_streams(
|
|||||||
let mut dead_rooms: Vec<Uuid> = Vec::new();
|
let mut dead_rooms: Vec<Uuid> = Vec::new();
|
||||||
|
|
||||||
for room_id in room_ids {
|
for room_id in room_ids {
|
||||||
if let Some((msg_stream, chunk_stream)) = streams.get_mut(&room_id) {
|
if let Some((msg_stream, chunk_stream, typing_stream)) = streams.get_mut(&room_id) {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
result = msg_stream.next() => {
|
result = msg_stream.next() => {
|
||||||
match result {
|
match result {
|
||||||
@ -412,6 +453,16 @@ async fn poll_push_streams(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
result = typing_stream.next() => {
|
||||||
|
match result {
|
||||||
|
Some(Ok(event)) => {
|
||||||
|
return Some(WsPushEvent::TypingIndicator { room_id, event });
|
||||||
|
}
|
||||||
|
Some(Err(_)) | None => {
|
||||||
|
// Typing channel going dead is non-fatal — typing is ephemeral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -424,9 +475,11 @@ async fn poll_push_streams(
|
|||||||
if service.room.check_room_access(room_id, user_id).await.is_ok() {
|
if service.room.check_room_access(room_id, user_id).await.is_ok() {
|
||||||
if let Ok(rx) = manager.subscribe(room_id, user_id).await {
|
if let Ok(rx) = manager.subscribe(room_id, user_id).await {
|
||||||
let stream_rx = manager.subscribe_room_stream(room_id).await;
|
let stream_rx = manager.subscribe_room_stream(room_id).await;
|
||||||
|
let typing_rx = manager.subscribe_typing(room_id).await;
|
||||||
streams.insert(room_id, (
|
streams.insert(room_id, (
|
||||||
BroadcastStream::new(rx),
|
BroadcastStream::new(rx),
|
||||||
BroadcastStream::new(stream_rx),
|
BroadcastStream::new(stream_rx),
|
||||||
|
BroadcastStream::new(typing_rx),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,10 +25,6 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
tokio = { workspace = true, features = ["rt"] }
|
tokio = { workspace = true, features = ["rt"] }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|
||||||
# Redis (for metrics exporter)
|
|
||||||
redis = { workspace = true }
|
|
||||||
deadpool-redis = { workspace = true, features = ["cluster"] }
|
|
||||||
|
|
||||||
# Prometheus metrics export
|
# Prometheus metrics export
|
||||||
metrics = "0.22"
|
metrics = "0.22"
|
||||||
metrics-exporter-prometheus = "0.13"
|
metrics-exporter-prometheus = "0.13"
|
||||||
|
|||||||
@ -9,7 +9,6 @@ pub mod metrics_middleware;
|
|||||||
pub mod prometheus_exporter;
|
pub mod prometheus_exporter;
|
||||||
pub mod otlp;
|
pub mod otlp;
|
||||||
pub mod tracing_middleware;
|
pub mod tracing_middleware;
|
||||||
pub mod redis_metrics;
|
|
||||||
|
|
||||||
pub use tracing_fmt::{init_tracing_subscriber, instance_id};
|
pub use tracing_fmt::{init_tracing_subscriber, instance_id};
|
||||||
pub use metrics_middleware::{MetricsMiddleware, HttpMetrics};
|
pub use metrics_middleware::{MetricsMiddleware, HttpMetrics};
|
||||||
@ -19,9 +18,3 @@ pub use prometheus_exporter::{
|
|||||||
};
|
};
|
||||||
pub use otlp::{init_otlp, OtelGuard};
|
pub use otlp::{init_otlp, OtelGuard};
|
||||||
pub use tracing_middleware::TracingSpanMiddleware;
|
pub use tracing_middleware::TracingSpanMiddleware;
|
||||||
pub use redis_metrics::{
|
|
||||||
MetricsSnapshot,
|
|
||||||
spawn_redis_metrics_flusher,
|
|
||||||
query_instance_metrics, query_all_instance_metrics, export_all_metrics_csv,
|
|
||||||
FlatInstanceMetrics,
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,342 +0,0 @@
|
|||||||
//! Redis-backed metrics exporter.
|
|
||||||
//!
|
|
||||||
//! Every `interval` seconds this module pushes metric fields to a Redis hash
|
|
||||||
//! bucketed by day:
|
|
||||||
//! Key: `metrics:{instance_id}:{YYYY-MM-DD}` (hash)
|
|
||||||
//! TTL: 30 days per day bucket
|
|
||||||
//!
|
|
||||||
//! Also maintains a sorted set `metrics:index:{instance_id}:{YYYY-MM-DD}` per
|
|
||||||
//! day for querying within a day window.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use deadpool_redis::cluster::Pool;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
const METRICS_TTL_SECS: i64 = 30 * 24 * 60 * 60; // 30 days
|
|
||||||
|
|
||||||
/// A snapshot of metric values at a point in time.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct MetricsSnapshot {
|
|
||||||
pub instance_id: String,
|
|
||||||
pub timestamp_secs: i64,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub http: HashMap<String, serde_json::Value>,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub room: HashMap<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Starts a background task that snapshots and pushes all metrics to Redis
|
|
||||||
/// every `interval` seconds.
|
|
||||||
///
|
|
||||||
/// `prometheus_handle`: from `install_recorder()`, used to render RoomMetrics values
|
|
||||||
/// `http_metrics`: shared `HttpMetrics` from `MetricsMiddleware`
|
|
||||||
/// `instance_id`: used as the Redis key prefix
|
|
||||||
pub fn spawn_redis_metrics_flusher(
|
|
||||||
redis_pool: Pool,
|
|
||||||
instance_id: String,
|
|
||||||
prometheus_handle: Arc<metrics_exporter_prometheus::PrometheusHandle>,
|
|
||||||
http_metrics: Arc<crate::metrics_middleware::HttpMetrics>,
|
|
||||||
interval: Duration,
|
|
||||||
) {
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut ticker = tokio::time::interval(interval);
|
|
||||||
loop {
|
|
||||||
ticker.tick().await;
|
|
||||||
if let Err(e) = flush_metrics(&redis_pool, &instance_id, &prometheus_handle, http_metrics.as_ref()).await {
|
|
||||||
tracing::warn!(error = %e, "failed to flush metrics to Redis");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn flush_metrics(
|
|
||||||
pool: &Pool,
|
|
||||||
instance_id: &str,
|
|
||||||
prometheus_handle: &Arc<metrics_exporter_prometheus::PrometheusHandle>,
|
|
||||||
http_metrics: &crate::metrics_middleware::HttpMetrics,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let now_ts = now.timestamp();
|
|
||||||
let day_key = now.format("%Y-%m-%d").to_string();
|
|
||||||
|
|
||||||
// RoomMetrics from the metrics crate (rendered via PrometheusHandle)
|
|
||||||
let body = prometheus_handle.render();
|
|
||||||
let room = crate::prometheus_exporter::render_to_hashmap(&body);
|
|
||||||
|
|
||||||
// HTTP metrics from AtomicU64
|
|
||||||
let http = http_metrics.snapshot();
|
|
||||||
|
|
||||||
let hash_key = format!("metrics:{}:{}", instance_id, day_key);
|
|
||||||
let index_key = format!("metrics:index:{}:{}", instance_id, day_key);
|
|
||||||
|
|
||||||
let mut conn = pool.get().await?;
|
|
||||||
|
|
||||||
// HSET each metric field into the daily hash
|
|
||||||
let mut fields: Vec<(String, String)> = Vec::with_capacity(http.len() + room.len());
|
|
||||||
for (k, v) in &http {
|
|
||||||
fields.push((format!("http_{}", k), v.to_string()));
|
|
||||||
}
|
|
||||||
for (k, v) in &room {
|
|
||||||
fields.push((format!("room_{}", k), v.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// HSET field field_value ...
|
|
||||||
let mut cmd = redis::cmd("HSET");
|
|
||||||
cmd.arg(&hash_key);
|
|
||||||
for (f, val) in &fields {
|
|
||||||
cmd.arg(f).arg(val);
|
|
||||||
}
|
|
||||||
let _: () = cmd.query_async(&mut conn).await?;
|
|
||||||
|
|
||||||
// Set TTL on the hash key (refresh every write)
|
|
||||||
let _: () = redis::cmd("EXPIRE")
|
|
||||||
.arg(&hash_key)
|
|
||||||
.arg(METRICS_TTL_SECS)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Update sorted set index for this instance+day
|
|
||||||
let _: () = redis::cmd("ZADD")
|
|
||||||
.arg(&index_key)
|
|
||||||
.arg(now_ts as f64)
|
|
||||||
.arg(now_ts.to_string())
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Trim index to last 1000 entries per day
|
|
||||||
let _: () = redis::cmd("ZREMRANGEBYRANK")
|
|
||||||
.arg(&index_key)
|
|
||||||
.arg(0)
|
|
||||||
.arg(-1001)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Set TTL on the index key
|
|
||||||
let _: () = redis::cmd("EXPIRE")
|
|
||||||
.arg(&index_key)
|
|
||||||
.arg(METRICS_TTL_SECS)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tracing::debug!(key = %hash_key, field_count = fields.len(), "metrics flushed to Redis (daily hash)");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Query metrics for an instance from Redis, reading the most recent `limit`
|
|
||||||
/// snapshots across all available daily hash buckets.
|
|
||||||
/// Returns a vector sorted by timestamp ascending.
|
|
||||||
pub async fn query_instance_metrics(
|
|
||||||
pool: &Pool,
|
|
||||||
instance_id: &str,
|
|
||||||
limit: usize,
|
|
||||||
) -> anyhow::Result<Vec<(i64, MetricsSnapshot)>> {
|
|
||||||
let mut conn = pool.get().await?;
|
|
||||||
|
|
||||||
// Scan for all daily index keys for this instance
|
|
||||||
let index_pattern = format!("metrics:index:{}:*", instance_id);
|
|
||||||
let mut cursor = 0u64;
|
|
||||||
let mut day_keys: Vec<String> = Vec::new();
|
|
||||||
loop {
|
|
||||||
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
|
||||||
.arg(cursor)
|
|
||||||
.arg("MATCH")
|
|
||||||
.arg(&index_pattern)
|
|
||||||
.arg("COUNT")
|
|
||||||
.arg(100)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
day_keys.extend(keys);
|
|
||||||
cursor = new_cursor;
|
|
||||||
if cursor == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all timestamps from all daily indexes
|
|
||||||
let mut all_timestamps: Vec<i64> = Vec::new();
|
|
||||||
for day_key in &day_keys {
|
|
||||||
let timestamps: Vec<i64> = redis::cmd("ZREVRANGE")
|
|
||||||
.arg(day_key)
|
|
||||||
.arg(0)
|
|
||||||
.arg((limit as isize - 1).max(0))
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
all_timestamps.extend(timestamps);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort descending and take limit
|
|
||||||
all_timestamps.sort_by_key(|&ts| std::cmp::Reverse(ts));
|
|
||||||
all_timestamps.truncate(limit);
|
|
||||||
|
|
||||||
// Read each snapshot from the daily hash
|
|
||||||
let mut results = Vec::with_capacity(all_timestamps.len());
|
|
||||||
for ts in &all_timestamps {
|
|
||||||
// Derive day bucket from timestamp
|
|
||||||
let dt = chrono::DateTime::from_timestamp(*ts, 0)
|
|
||||||
.unwrap_or_else(|| chrono::Utc::now());
|
|
||||||
let day_str = dt.format("%Y-%m-%d").to_string();
|
|
||||||
let hash_key = format!("metrics:{}:{}", instance_id, day_str);
|
|
||||||
|
|
||||||
let fields: Option<HashMap<String, String>> = redis::cmd("HGETALL")
|
|
||||||
.arg(&hash_key)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(fields) = fields {
|
|
||||||
let mut http: HashMap<String, serde_json::Value> = HashMap::new();
|
|
||||||
let mut room: HashMap<String, serde_json::Value> = HashMap::new();
|
|
||||||
for (k, v) in &fields {
|
|
||||||
let json_val: serde_json::Value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.clone()));
|
|
||||||
if let Some(stripped) = k.strip_prefix("http_") {
|
|
||||||
http.insert(stripped.to_string(), json_val);
|
|
||||||
} else if let Some(stripped) = k.strip_prefix("room_") {
|
|
||||||
room.insert(stripped.to_string(), json_val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results.push((*ts, MetricsSnapshot {
|
|
||||||
instance_id: instance_id.to_string(),
|
|
||||||
timestamp_secs: *ts,
|
|
||||||
http,
|
|
||||||
room,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results.sort_by_key(|(ts, _)| *ts);
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flat instance metrics suitable for JSON serialization.
|
|
||||||
#[derive(serde::Serialize)]
|
|
||||||
pub struct FlatInstanceMetrics {
|
|
||||||
pub instance_id: String,
|
|
||||||
pub timestamp_secs: i64,
|
|
||||||
pub http: std::collections::HashMap<String, String>,
|
|
||||||
pub room: std::collections::HashMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Query all instances' metrics, optionally filtered by `instance_filter`.
|
|
||||||
pub async fn query_all_instance_metrics(
|
|
||||||
pool: &Pool,
|
|
||||||
instance_filter: &str,
|
|
||||||
limit: usize,
|
|
||||||
) -> anyhow::Result<Vec<FlatInstanceMetrics>> {
|
|
||||||
let mut conn = pool.get().await?;
|
|
||||||
|
|
||||||
// Scan for all daily index keys to extract instance ids
|
|
||||||
let mut cursor = 0u64;
|
|
||||||
let mut all_instance_ids = Vec::new();
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
loop {
|
|
||||||
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
|
||||||
.arg(cursor)
|
|
||||||
.arg("MATCH")
|
|
||||||
.arg("metrics:index:*")
|
|
||||||
.arg("COUNT")
|
|
||||||
.arg(100)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
for k in keys {
|
|
||||||
// Key format: metrics:index:{instance}:{YYYY-MM-DD}
|
|
||||||
if let Some(rest) = k.strip_prefix("metrics:index:") {
|
|
||||||
if let Some((instance_id, _)) = rest.rsplit_once(':') {
|
|
||||||
if seen.insert(instance_id.to_string()) {
|
|
||||||
all_instance_ids.push(instance_id.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor = new_cursor;
|
|
||||||
if cursor == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut results = Vec::new();
|
|
||||||
for instance_id in all_instance_ids {
|
|
||||||
if !instance_filter.is_empty() && !instance_id.contains(instance_filter) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let snapshots = query_instance_metrics(pool, &instance_id, limit).await?;
|
|
||||||
for (_, payload) in snapshots {
|
|
||||||
let mut http: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
|
||||||
let mut room: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
|
||||||
for (k, v) in &payload.http {
|
|
||||||
http.insert(k.clone(), v.to_string());
|
|
||||||
}
|
|
||||||
for (k, v) in &payload.room {
|
|
||||||
room.insert(k.clone(), v.to_string());
|
|
||||||
}
|
|
||||||
results.push(FlatInstanceMetrics {
|
|
||||||
instance_id: payload.instance_id,
|
|
||||||
timestamp_secs: payload.timestamp_secs,
|
|
||||||
http,
|
|
||||||
room,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export all metrics across all known instances as CSV.
|
|
||||||
pub async fn export_all_metrics_csv(
|
|
||||||
pool: &Pool,
|
|
||||||
instance_filter: &str,
|
|
||||||
) -> anyhow::Result<String> {
|
|
||||||
let mut conn = pool.get().await?;
|
|
||||||
|
|
||||||
// Scan for all daily index keys to extract instance ids
|
|
||||||
let mut cursor = 0u64;
|
|
||||||
let mut all_instance_ids = Vec::new();
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
loop {
|
|
||||||
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
|
||||||
.arg(cursor)
|
|
||||||
.arg("MATCH")
|
|
||||||
.arg("metrics:index:*")
|
|
||||||
.arg("COUNT")
|
|
||||||
.arg(100)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
for k in keys {
|
|
||||||
// Key format: metrics:index:{instance}:{YYYY-MM-DD}
|
|
||||||
if let Some(rest) = k.strip_prefix("metrics:index:") {
|
|
||||||
if let Some(instance_id) = rest.rsplit_once(':') {
|
|
||||||
let id = instance_id.0;
|
|
||||||
if seen.insert(id.to_string()) {
|
|
||||||
all_instance_ids.push(id.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor = new_cursor;
|
|
||||||
if cursor == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rows: Vec<String> = vec![
|
|
||||||
"instance_id,timestamp,metric,value".to_string(),
|
|
||||||
];
|
|
||||||
|
|
||||||
for instance_id in all_instance_ids {
|
|
||||||
if !instance_filter.is_empty() && !instance_id.contains(instance_filter) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let metrics = query_instance_metrics(pool, &instance_id, 1000).await?;
|
|
||||||
for (ts, payload) in metrics {
|
|
||||||
for (k, v) in &payload.http {
|
|
||||||
rows.push(format!("{},{},http_{},{}", instance_id, ts, k, v));
|
|
||||||
}
|
|
||||||
for (k, v) in &payload.room {
|
|
||||||
rows.push(format!("{},{},room_{},{}", instance_id, ts, k, v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(rows.join("\n"))
|
|
||||||
}
|
|
||||||
@ -7,7 +7,7 @@ pub mod worker;
|
|||||||
pub use producer::{MessageProducer, RedisPubSub};
|
pub use producer::{MessageProducer, RedisPubSub};
|
||||||
pub use types::{
|
pub use types::{
|
||||||
AgentTaskEvent, EmailEnvelope, ProjectRoomEvent, ReactionGroup, RoomMessageEnvelope,
|
AgentTaskEvent, EmailEnvelope, ProjectRoomEvent, ReactionGroup, RoomMessageEnvelope,
|
||||||
RoomMessageEvent, RoomMessageStreamChunkEvent,
|
RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent,
|
||||||
};
|
};
|
||||||
pub use worker::{
|
pub use worker::{
|
||||||
room_worker_task, start as start_worker, start_email_worker, EmailSendFn, EmailSendFut, GetRedis,
|
room_worker_task, start as start_worker, start_email_worker, EmailSendFn, EmailSendFut, GetRedis,
|
||||||
|
|||||||
@ -42,6 +42,17 @@ pub struct RoomMessageEvent {
|
|||||||
pub message_id: Option<Uuid>,
|
pub message_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Typing indicator event — broadcast to all room members.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct TypingEvent {
|
||||||
|
pub room_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub username: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
/// "start" or "stop"
|
||||||
|
pub action: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ReactionGroup {
|
pub struct ReactionGroup {
|
||||||
pub emoji: String,
|
pub emoji: String,
|
||||||
|
|||||||
@ -7,8 +7,10 @@ use std::time::{Duration, Instant};
|
|||||||
use tokio::sync::{RwLock, broadcast};
|
use tokio::sync::{RwLock, broadcast};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use db::cache::AppCache;
|
||||||
use db::database::AppDatabase;
|
use db::database::AppDatabase;
|
||||||
use models::rooms::{MessageContentType, MessageSenderType, room_message};
|
use models::rooms::{MessageContentType, MessageSenderType, room_message};
|
||||||
|
use queue::types::TypingEvent;
|
||||||
use queue::{AgentTaskEvent, ProjectRoomEvent, RoomMessageEnvelope, RoomMessageEvent, RoomMessageStreamChunkEvent};
|
use queue::{AgentTaskEvent, ProjectRoomEvent, RoomMessageEnvelope, RoomMessageEvent, RoomMessageStreamChunkEvent};
|
||||||
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
|
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
|
||||||
@ -33,6 +35,7 @@ pub struct RoomConnectionManager {
|
|||||||
/// Broadcast channel for agent task events per project.
|
/// Broadcast channel for agent task events per project.
|
||||||
task_inner: RwLock<HashMap<Uuid, broadcast::Sender<Arc<AgentTaskEvent>>>>,
|
task_inner: RwLock<HashMap<Uuid, broadcast::Sender<Arc<AgentTaskEvent>>>>,
|
||||||
pub metrics: Arc<RoomMetrics>,
|
pub metrics: Arc<RoomMetrics>,
|
||||||
|
cache: AppCache,
|
||||||
connection_rate: RwLock<HashMap<(Uuid, Uuid), Instant>>,
|
connection_rate: RwLock<HashMap<(Uuid, Uuid), Instant>>,
|
||||||
shutdown_tx: broadcast::Sender<()>,
|
shutdown_tx: broadcast::Sender<()>,
|
||||||
room_shutdown_txs: RwLock<HashMap<Uuid, broadcast::Sender<()>>>,
|
room_shutdown_txs: RwLock<HashMap<Uuid, broadcast::Sender<()>>>,
|
||||||
@ -40,6 +43,7 @@ pub struct RoomConnectionManager {
|
|||||||
user_shutdown_txs: RwLock<HashMap<Uuid, broadcast::Sender<()>>>,
|
user_shutdown_txs: RwLock<HashMap<Uuid, broadcast::Sender<()>>>,
|
||||||
stream_inner: RwLock<HashMap<Uuid, broadcast::Sender<Arc<RoomMessageStreamChunkEvent>>>>,
|
stream_inner: RwLock<HashMap<Uuid, broadcast::Sender<Arc<RoomMessageStreamChunkEvent>>>>,
|
||||||
room_stream_inner: RwLock<HashMap<Uuid, broadcast::Sender<Arc<RoomMessageStreamChunkEvent>>>>,
|
room_stream_inner: RwLock<HashMap<Uuid, broadcast::Sender<Arc<RoomMessageStreamChunkEvent>>>>,
|
||||||
|
typing_inner: RwLock<HashMap<Uuid, broadcast::Sender<Arc<TypingEvent>>>>,
|
||||||
room_last_activity: RwLock<HashMap<Uuid, Instant>>,
|
room_last_activity: RwLock<HashMap<Uuid, Instant>>,
|
||||||
room_subscriber_count: RwLock<HashMap<Uuid, usize>>,
|
room_subscriber_count: RwLock<HashMap<Uuid, usize>>,
|
||||||
project_subscriber_count: RwLock<HashMap<Uuid, usize>>,
|
project_subscriber_count: RwLock<HashMap<Uuid, usize>>,
|
||||||
@ -47,7 +51,7 @@ pub struct RoomConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RoomConnectionManager {
|
impl RoomConnectionManager {
|
||||||
pub fn new(metrics: Arc<RoomMetrics>) -> Self {
|
pub fn new(metrics: Arc<RoomMetrics>, cache: AppCache) -> Self {
|
||||||
let (shutdown_tx, _) = broadcast::channel(SHUTDOWN_CHANNEL_CAPACITY);
|
let (shutdown_tx, _) = broadcast::channel(SHUTDOWN_CHANNEL_CAPACITY);
|
||||||
Self {
|
Self {
|
||||||
#[allow(clippy::default_constructed_unit_structs)]
|
#[allow(clippy::default_constructed_unit_structs)]
|
||||||
@ -61,6 +65,7 @@ impl RoomConnectionManager {
|
|||||||
#[allow(clippy::default_constructed_unit_structs)]
|
#[allow(clippy::default_constructed_unit_structs)]
|
||||||
task_inner: RwLock::new(HashMap::new()),
|
task_inner: RwLock::new(HashMap::new()),
|
||||||
metrics,
|
metrics,
|
||||||
|
cache,
|
||||||
#[allow(clippy::default_constructed_unit_structs)]
|
#[allow(clippy::default_constructed_unit_structs)]
|
||||||
connection_rate: RwLock::new(HashMap::new()),
|
connection_rate: RwLock::new(HashMap::new()),
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
@ -75,6 +80,8 @@ impl RoomConnectionManager {
|
|||||||
#[allow(clippy::default_constructed_unit_structs)]
|
#[allow(clippy::default_constructed_unit_structs)]
|
||||||
room_stream_inner: RwLock::new(HashMap::new()),
|
room_stream_inner: RwLock::new(HashMap::new()),
|
||||||
#[allow(clippy::default_constructed_unit_structs)]
|
#[allow(clippy::default_constructed_unit_structs)]
|
||||||
|
typing_inner: RwLock::new(HashMap::new()),
|
||||||
|
#[allow(clippy::default_constructed_unit_structs)]
|
||||||
room_last_activity: RwLock::new(HashMap::new()),
|
room_last_activity: RwLock::new(HashMap::new()),
|
||||||
#[allow(clippy::default_constructed_unit_structs)]
|
#[allow(clippy::default_constructed_unit_structs)]
|
||||||
room_subscriber_count: RwLock::new(HashMap::new()),
|
room_subscriber_count: RwLock::new(HashMap::new()),
|
||||||
@ -621,6 +628,57 @@ impl RoomConnectionManager {
|
|||||||
let mut map = self.stream_inner.write().await;
|
let mut map = self.stream_inner.write().await;
|
||||||
map.remove(&message_id);
|
map.remove(&message_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn subscribe_typing(
|
||||||
|
&self,
|
||||||
|
room_id: Uuid,
|
||||||
|
) -> broadcast::Receiver<Arc<TypingEvent>> {
|
||||||
|
let mut map: tokio::sync::RwLockWriteGuard<'_, std::collections::HashMap<Uuid, broadcast::Sender<Arc<TypingEvent>>>> = self.typing_inner.write().await;
|
||||||
|
if let Some(tx) = map.get(&room_id) {
|
||||||
|
return tx.subscribe();
|
||||||
|
}
|
||||||
|
let (tx, rx) = broadcast::channel(BROADCAST_CAPACITY);
|
||||||
|
map.insert(room_id, tx);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast a typing event and persist it to Redis with 10s TTL.
|
||||||
|
/// - "start": writes key with 10s expiry, broadcasts start event
|
||||||
|
/// - "stop": deletes key, broadcasts stop event
|
||||||
|
pub async fn broadcast_typing(&self, room_id: Uuid, event: TypingEvent) {
|
||||||
|
let user_key = format!("typing:{}:{}", room_id, event.user_id);
|
||||||
|
let action = event.action.clone();
|
||||||
|
let username = event.username.clone();
|
||||||
|
let avatar_url = event.avatar_url.clone();
|
||||||
|
|
||||||
|
// Write/delete Redis key for 10s expiry (non-blocking)
|
||||||
|
if let Ok(mut conn) = self.cache.conn().await {
|
||||||
|
let key = user_key;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if action == "start" {
|
||||||
|
let value = serde_json::json!({
|
||||||
|
"username": username,
|
||||||
|
"avatar_url": avatar_url,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let _: Result<(), _> = redis::cmd("SETEX")
|
||||||
|
.arg(&key)
|
||||||
|
.arg(10i64)
|
||||||
|
.arg(&value)
|
||||||
|
.query_async(&mut conn)
|
||||||
|
.await;
|
||||||
|
} else {
|
||||||
|
let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let map: tokio::sync::RwLockReadGuard<'_, std::collections::HashMap<Uuid, broadcast::Sender<Arc<TypingEvent>>>> = self.typing_inner.read().await;
|
||||||
|
if let Some(tx) = map.get(&room_id) {
|
||||||
|
let event = Arc::new(event);
|
||||||
|
let _ = tx.send(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_sender_type(s: &str) -> MessageSenderType {
|
fn parse_sender_type(s: &str) -> MessageSenderType {
|
||||||
@ -738,15 +796,24 @@ pub fn make_persist_fn(
|
|||||||
.exec(&db)
|
.exec(&db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Update content_tsv for inserted messages
|
// Batch update content_tsv using a single UPDATE with subquery
|
||||||
for env in chunk.iter() {
|
// instead of N individual UPDATE statements (N=chunk size, up to 100)
|
||||||
let update_sql = format!(
|
let ids: Vec<String> = chunk
|
||||||
"UPDATE room_message SET content_tsv = to_tsvector('simple', content) WHERE id = '{}'",
|
.iter()
|
||||||
env.id
|
.filter(|e| !existing_ids.contains(&e.id))
|
||||||
|
.map(|e| format!("'{}'", e.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !ids.is_empty() {
|
||||||
|
let batch_sql = format!(
|
||||||
|
"UPDATE room_message AS t \
|
||||||
|
SET content_tsv = to_tsvector('simple', content) \
|
||||||
|
WHERE t.id IN ({})",
|
||||||
|
ids.join(",")
|
||||||
);
|
);
|
||||||
let stmt = sea_orm::Statement::from_sql_and_values(
|
let stmt = sea_orm::Statement::from_sql_and_values(
|
||||||
sea_orm::DbBackend::Postgres,
|
sea_orm::DbBackend::Postgres,
|
||||||
&update_sql,
|
&batch_sql,
|
||||||
vec![],
|
vec![],
|
||||||
);
|
);
|
||||||
let _ = db.execute_raw(stmt).await;
|
let _ = db.execute_raw(stmt).await;
|
||||||
|
|||||||
@ -284,7 +284,7 @@ impl RoomService {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let should_respond = match self.should_ai_respond(room_id).await {
|
let should_respond = match self.should_ai_respond(room_id, &content).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(room_id = %room_id, error = %e, "should_ai_respond failed");
|
tracing::warn!(room_id = %room_id, error = %e, "should_ai_respond failed");
|
||||||
|
|||||||
@ -756,7 +756,16 @@ impl RoomService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn should_ai_respond(&self, room_id: Uuid) -> Result<bool, RoomError> {
|
/// Determine whether AI should respond to a message in this room.
|
||||||
|
/// - No room_ai config → AI not configured, never respond.
|
||||||
|
/// - use_exact = false → respond to every text message.
|
||||||
|
/// - use_exact = true → only respond when the message contains an @[ai:...] or
|
||||||
|
/// <mention type="ai">... tag that mentions this room's configured AI model.
|
||||||
|
pub async fn should_ai_respond(
|
||||||
|
&self,
|
||||||
|
room_id: Uuid,
|
||||||
|
content: &str,
|
||||||
|
) -> Result<bool, RoomError> {
|
||||||
use models::rooms::room_ai;
|
use models::rooms::room_ai;
|
||||||
|
|
||||||
let ai_config = room_ai::Entity::find()
|
let ai_config = room_ai::Entity::find()
|
||||||
@ -764,7 +773,37 @@ impl RoomService {
|
|||||||
.one(&self.db)
|
.one(&self.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(ai_config.is_some())
|
let config = match ai_config {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !config.use_exact {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// use_exact mode: only respond when AI is explicitly mentioned
|
||||||
|
let model_id_str = config.model.to_string();
|
||||||
|
|
||||||
|
// Check @[ai:model_id:label] format
|
||||||
|
for cap in MENTION_BRACKET_RE.captures_iter(content) {
|
||||||
|
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||||
|
if type_m.as_str() == "ai" && id_m.as_str().trim() == model_id_str {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check <mention type="ai" id="model_id">label</mention> format
|
||||||
|
for cap in MENTION_TAG_RE.captures_iter(content) {
|
||||||
|
if let (Some(type_m), Some(id_m)) = (cap.get(1), cap.get(2)) {
|
||||||
|
if type_m.as_str() == "ai" && id_m.as_str().trim() == model_id_str {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_room_ai_config(
|
pub async fn get_room_ai_config(
|
||||||
@ -1179,6 +1218,20 @@ impl RoomService {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "AI processing failed");
|
tracing::error!(error = %e, "AI processing failed");
|
||||||
|
// Send an error message so the user knows something went wrong
|
||||||
|
let _ = Self::create_and_publish_ai_message(
|
||||||
|
&db,
|
||||||
|
&cache,
|
||||||
|
&queue,
|
||||||
|
&room_manager,
|
||||||
|
room_id_for_ai,
|
||||||
|
project_id_for_ai,
|
||||||
|
Uuid::now_v7(),
|
||||||
|
format!("[AI error: {}]", e),
|
||||||
|
model_id_inner,
|
||||||
|
Some(model_display_name),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1249,6 +1302,19 @@ impl RoomService {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = %e, "ReAct agent failed");
|
tracing::error!(error = %e, "ReAct agent failed");
|
||||||
|
let _ = Self::create_and_publish_ai_message(
|
||||||
|
&db,
|
||||||
|
&cache,
|
||||||
|
&queue,
|
||||||
|
&room_manager,
|
||||||
|
room_id_for_ai,
|
||||||
|
project_id_for_ai,
|
||||||
|
Uuid::now_v7(),
|
||||||
|
format!("[AI error: {}]", e),
|
||||||
|
model_id_inner,
|
||||||
|
Some(model_display_name),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1288,7 +1354,9 @@ impl RoomService {
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _lock_guard = lock_guard;
|
let _lock_guard = lock_guard;
|
||||||
|
|
||||||
// Buffer each ReactStep and forward as a stream chunk.
|
// Buffer all reasoning steps + the final answer separately.
|
||||||
|
let reasoning_buffer: std::sync::Arc<std::sync::Mutex<String>> =
|
||||||
|
std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
||||||
let answer_buffer: std::sync::Arc<std::sync::Mutex<String>> =
|
let answer_buffer: std::sync::Arc<std::sync::Mutex<String>> =
|
||||||
std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
||||||
let step_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
let step_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
@ -1299,6 +1367,7 @@ impl RoomService {
|
|||||||
let room_id = room_id_inner;
|
let room_id = room_id_inner;
|
||||||
let step_count = step_count.clone();
|
let step_count = step_count.clone();
|
||||||
let ai_display_name_for_step = std::sync::Arc::new(ai_display_name.clone());
|
let ai_display_name_for_step = std::sync::Arc::new(ai_display_name.clone());
|
||||||
|
let reasoning_buffer = reasoning_buffer.clone();
|
||||||
let answer_buffer = answer_buffer.clone();
|
let answer_buffer = answer_buffer.clone();
|
||||||
move |step: ReactStep| {
|
move |step: ReactStep| {
|
||||||
let room_manager = room_manager.clone();
|
let room_manager = room_manager.clone();
|
||||||
@ -1320,31 +1389,35 @@ impl RoomService {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let ReactStep::Answer { .. } = &step {
|
let is_answer = matches!(&step, ReactStep::Answer { .. });
|
||||||
|
if is_answer {
|
||||||
step_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
step_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
let done = matches!(&step, ReactStep::Answer { .. });
|
let done = is_answer;
|
||||||
let content_for_buffer = if done {
|
|
||||||
content.clone()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let ai_name = ai_display_name_for_step.clone();
|
let ai_name = ai_display_name_for_step.clone();
|
||||||
|
let reasoning_buf = reasoning_buffer.clone();
|
||||||
let answer_buf = answer_buffer.clone();
|
let answer_buf = answer_buffer.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// Always broadcast every step as a stream chunk
|
||||||
let event = RoomMessageStreamChunkEvent {
|
let event = RoomMessageStreamChunkEvent {
|
||||||
message_id: streaming_msg_id,
|
message_id: streaming_msg_id,
|
||||||
room_id,
|
room_id,
|
||||||
content,
|
content: content.clone(),
|
||||||
done,
|
done,
|
||||||
error: None,
|
error: None,
|
||||||
display_name: Some((*ai_name).clone()),
|
display_name: Some((*ai_name).clone()),
|
||||||
};
|
};
|
||||||
room_manager.broadcast_stream_chunk(event).await;
|
room_manager.broadcast_stream_chunk(event).await;
|
||||||
if done {
|
|
||||||
answer_buf.lock().unwrap().push_str(&content_for_buffer);
|
// Collect all steps into reasoning_buffer; Answer goes to answer_buffer
|
||||||
|
let mut rb = reasoning_buf.lock().unwrap();
|
||||||
|
rb.push_str(&content);
|
||||||
|
rb.push('\n');
|
||||||
|
drop(rb);
|
||||||
|
if is_answer {
|
||||||
|
answer_buf.lock().unwrap().push_str(&content);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1355,9 +1428,47 @@ impl RoomService {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let final_content = answer_buffer.lock().unwrap().clone();
|
let final_content = answer_buffer.lock().unwrap().clone();
|
||||||
|
let reasoning_chain = reasoning_buffer.lock().unwrap().clone();
|
||||||
|
|
||||||
|
// Determine what to persist: prefer the answer, fall back to the reasoning chain
|
||||||
|
let content_to_persist = if !final_content.is_empty() {
|
||||||
|
final_content
|
||||||
|
} else if !reasoning_chain.trim().is_empty() {
|
||||||
|
// No Answer step, but the reasoning chain was streamed — still send it
|
||||||
|
format!(
|
||||||
|
"[Agent ran through {} reasoning steps but did not produce a final answer.]\n{}",
|
||||||
|
step_count.load(std::sync::atomic::Ordering::Relaxed),
|
||||||
|
reasoning_chain.trim_end()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Nothing produced — this should not happen in practice
|
||||||
|
String::from("[No output from reasoning agent]")
|
||||||
|
};
|
||||||
|
|
||||||
|
let (err_msg, should_log) = match &result {
|
||||||
|
Err(e) => (Some(format!("[Agent error: {}]", e)), true),
|
||||||
|
_ => (None, false),
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_to_persist = if let Some(msg) = &err_msg {
|
||||||
|
format!(
|
||||||
|
"{}\n[Error during reasoning: {}]",
|
||||||
|
content_to_persist.trim_end(),
|
||||||
|
msg.trim_start_matches("[Agent error: ").trim_end_matches("]")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
content_to_persist
|
||||||
|
};
|
||||||
|
|
||||||
|
if should_log {
|
||||||
|
tracing::error!(error = %result.as_ref().unwrap_err(), "ReAct streaming failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
let persist_content = content_to_persist.trim().to_string();
|
||||||
|
if persist_content.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(_) if !final_content.is_empty() => {
|
|
||||||
let envelope = RoomMessageEnvelope {
|
let envelope = RoomMessageEnvelope {
|
||||||
id: streaming_msg_id,
|
id: streaming_msg_id,
|
||||||
dedup_key: Some(format!("{}:{}", room_id_inner, streaming_msg_id)),
|
dedup_key: Some(format!("{}:{}", room_id_inner, streaming_msg_id)),
|
||||||
@ -1366,7 +1477,7 @@ impl RoomService {
|
|||||||
sender_id: None,
|
sender_id: None,
|
||||||
model_id: Some(model_id_inner),
|
model_id: Some(model_id_inner),
|
||||||
thread_id: None,
|
thread_id: None,
|
||||||
content: final_content.clone(),
|
content: persist_content.clone(),
|
||||||
content_type: "text".to_string(),
|
content_type: "text".to_string(),
|
||||||
send_at: now,
|
send_at: now,
|
||||||
seq,
|
seq,
|
||||||
@ -1397,7 +1508,7 @@ impl RoomService {
|
|||||||
sender_type: sender_type.clone(),
|
sender_type: sender_type.clone(),
|
||||||
sender_id: None,
|
sender_id: None,
|
||||||
thread_id: None,
|
thread_id: None,
|
||||||
content: final_content,
|
content: persist_content,
|
||||||
content_type: "text".to_string(),
|
content_type: "text".to_string(),
|
||||||
send_at: now,
|
send_at: now,
|
||||||
seq,
|
seq,
|
||||||
@ -1422,23 +1533,6 @@ impl RoomService {
|
|||||||
.publish_project_room_event(project_id_inner, event)
|
.publish_project_room_event(project_id_inner, event)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::warn!("ReAct agent returned empty answer");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(error = %e, "ReAct streaming failed");
|
|
||||||
let event = RoomMessageStreamChunkEvent {
|
|
||||||
message_id: streaming_msg_id,
|
|
||||||
room_id: room_id_inner,
|
|
||||||
content: String::new(),
|
|
||||||
done: true,
|
|
||||||
error: Some(e.to_string()),
|
|
||||||
display_name: Some(ai_display_name.clone()),
|
|
||||||
};
|
|
||||||
room_manager.broadcast_stream_chunk(event).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
room_manager.close_stream_channel(streaming_msg_id).await;
|
room_manager.close_stream_channel(streaming_msg_id).await;
|
||||||
});
|
});
|
||||||
@ -1540,31 +1634,18 @@ impl RoomService {
|
|||||||
let mut conn = cache.conn().await.map_err(|e| {
|
let mut conn = cache.conn().await.map_err(|e| {
|
||||||
RoomError::Internal(format!("failed to get redis connection for seq: {}", e))
|
RoomError::Internal(format!("failed to get redis connection for seq: {}", e))
|
||||||
})?;
|
})?;
|
||||||
// Atomically increment and check via Lua: INCR first, then if Redis was
|
|
||||||
// externally set to a higher value, jump to max+1. This prevents concurrent
|
// Normal path: Redis INCR is atomic and sufficient for sequence generation.
|
||||||
// requests from getting duplicate seqs — the Lua script runs as one atomic unit.
|
// Lua script removed — it was executing on every single message (costly).
|
||||||
let seq: i64 = redis::cmd("EVAL")
|
let seq: i64 = redis::cmd("INCR")
|
||||||
.arg(
|
|
||||||
r#"
|
|
||||||
local current = redis.call('INCR', KEYS[1])
|
|
||||||
local stored = redis.call('GET', KEYS[1])
|
|
||||||
if stored and tonumber(stored) > current then
|
|
||||||
local next = tonumber(stored) + 1
|
|
||||||
redis.call('SET', KEYS[1], next)
|
|
||||||
return next
|
|
||||||
end
|
|
||||||
return current
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.arg(1)
|
|
||||||
.arg(&seq_key)
|
.arg(&seq_key)
|
||||||
.query_async(&mut conn)
|
.query_async(&mut conn)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RoomError::Internal(format!("seq Lua script: {}", e)))?;
|
.map_err(|e| RoomError::Internal(format!("seq INCR: {}", e)))?;
|
||||||
|
|
||||||
// Reconciliation check: if DB is ahead of Redis (e.g. server restart wiped
|
// DB reconciliation: only check every 1000 messages, not on every request.
|
||||||
// Redis), bump Redis to stay in sync. This query is only hit on the rare
|
// This handles the rare cross-server handoff case (Redis restart wipe).
|
||||||
// cross-server handoff case, not on every request.
|
if seq % 1000 == 0 {
|
||||||
use models::rooms::room_message::{Column as RmCol, Entity as RoomMessage};
|
use models::rooms::room_message::{Column as RmCol, Entity as RoomMessage};
|
||||||
use sea_orm::EntityTrait;
|
use sea_orm::EntityTrait;
|
||||||
let db_seq: Option<Option<Option<i64>>> = RoomMessage::find()
|
let db_seq: Option<Option<Option<i64>>> = RoomMessage::find()
|
||||||
@ -1578,15 +1659,15 @@ impl RoomService {
|
|||||||
let db_seq = db_seq.flatten().flatten().unwrap_or(0);
|
let db_seq = db_seq.flatten().flatten().unwrap_or(0);
|
||||||
|
|
||||||
if db_seq >= seq {
|
if db_seq >= seq {
|
||||||
// Another server handled this room while we were idle — catch up.
|
|
||||||
let _: String = redis::cmd("SET")
|
let _: String = redis::cmd("SET")
|
||||||
.arg(&seq_key)
|
.arg(&seq_key)
|
||||||
.arg(db_seq + 1)
|
.arg(db_seq + 1)
|
||||||
.query_async(&mut conn)
|
.query_async(&mut conn)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| RoomError::Internal(format!("SET seq: {}", e)))?;
|
.map_err(|e| RoomError::Internal(format!("seq SET: {}", e)))?;
|
||||||
return Ok(db_seq + 1);
|
return Ok(db_seq + 1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(seq)
|
Ok(seq)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,8 @@ pub enum RoomEventType {
|
|||||||
ReadReceipt,
|
ReadReceipt,
|
||||||
ReactionAdded,
|
ReactionAdded,
|
||||||
ReactionRemoved,
|
ReactionRemoved,
|
||||||
|
TypingStart,
|
||||||
|
TypingStop,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoomEventType {
|
impl RoomEventType {
|
||||||
@ -55,6 +57,8 @@ impl RoomEventType {
|
|||||||
RoomEventType::ReadReceipt => "read_receipt",
|
RoomEventType::ReadReceipt => "read_receipt",
|
||||||
RoomEventType::ReactionAdded => "reaction_added",
|
RoomEventType::ReactionAdded => "reaction_added",
|
||||||
RoomEventType::ReactionRemoved => "reaction_removed",
|
RoomEventType::ReactionRemoved => "reaction_removed",
|
||||||
|
RoomEventType::TypingStart => "typing_start",
|
||||||
|
RoomEventType::TypingStop => "typing_stop",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +81,8 @@ impl RoomEventType {
|
|||||||
"read_receipt" => Some(RoomEventType::ReadReceipt),
|
"read_receipt" => Some(RoomEventType::ReadReceipt),
|
||||||
"reaction_added" => Some(RoomEventType::ReactionAdded),
|
"reaction_added" => Some(RoomEventType::ReactionAdded),
|
||||||
"reaction_removed" => Some(RoomEventType::ReactionRemoved),
|
"reaction_removed" => Some(RoomEventType::ReactionRemoved),
|
||||||
|
"typing_start" => Some(RoomEventType::TypingStart),
|
||||||
|
"typing_stop" => Some(RoomEventType::TypingStop),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,12 +7,11 @@ use tonic::{transport::Server, Request, Response, Status};
|
|||||||
use tracing::{info_span, Instrument};
|
use tracing::{info_span, Instrument};
|
||||||
|
|
||||||
use super::generated::admin::{
|
use super::generated::admin::{
|
||||||
GetMetricsRequest, GetMetricsResponse, GetUserInfoRequest, GetUserInfoResponse,
|
GetUserInfoRequest, GetUserInfoResponse, GetUserStatusRequest, GetUserStatusResponse,
|
||||||
GetUserStatusRequest, GetUserStatusResponse, GetWorkspaceOnlineUsersRequest,
|
GetWorkspaceOnlineUsersRequest, GetWorkspaceOnlineUsersResponse, IsUserOnlineRequest,
|
||||||
GetWorkspaceOnlineUsersResponse, InstanceMetrics, IsUserOnlineRequest, IsUserOnlineResponse,
|
IsUserOnlineResponse, KickUserFromWorkspaceRequest, KickUserFromWorkspaceResponse,
|
||||||
KickUserFromWorkspaceRequest, KickUserFromWorkspaceResponse, KickUserRequest, KickUserResponse,
|
KickUserRequest, KickUserResponse, ListUserSessionsRequest, ListUserSessionsResponse,
|
||||||
ListUserSessionsRequest, ListUserSessionsResponse, ListWorkspaceSessionsRequest,
|
ListWorkspaceSessionsRequest, ListWorkspaceSessionsResponse,
|
||||||
ListWorkspaceSessionsResponse, ExportMetricsCsvRequest, ExportMetricsCsvResponse,
|
|
||||||
};
|
};
|
||||||
use super::generated::admin_session_admin::session_admin_server::{
|
use super::generated::admin_session_admin::session_admin_server::{
|
||||||
SessionAdmin, SessionAdminServer,
|
SessionAdmin, SessionAdminServer,
|
||||||
@ -205,40 +204,6 @@ impl SessionAdmin for SessionAdminService {
|
|||||||
.instrument(info_span!("is_user_online", user_id = %user_id))
|
.instrument(info_span!("is_user_online", user_id = %user_id))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_metrics(
|
|
||||||
&self,
|
|
||||||
req: Request<GetMetricsRequest>,
|
|
||||||
) -> Result<Response<GetMetricsResponse>, Status> {
|
|
||||||
let r = req.get_ref();
|
|
||||||
let instance_filter = r.instance_filter.as_str();
|
|
||||||
let limit = if r.limit > 0 { r.limit as usize } else { 100 };
|
|
||||||
|
|
||||||
async {
|
|
||||||
let pool = self.session_manager.pool();
|
|
||||||
let instances = query_all_instance_metrics(pool, instance_filter, limit).await
|
|
||||||
.map_err(|e| Status::internal(e.to_string()))?;
|
|
||||||
Ok(Response::new(GetMetricsResponse { instances }))
|
|
||||||
}
|
|
||||||
.instrument(info_span!("get_metrics"))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn export_metrics_csv(
|
|
||||||
&self,
|
|
||||||
req: Request<ExportMetricsCsvRequest>,
|
|
||||||
) -> Result<Response<ExportMetricsCsvResponse>, Status> {
|
|
||||||
let instance_filter = req.get_ref().instance_filter.as_str();
|
|
||||||
|
|
||||||
async {
|
|
||||||
let pool = self.session_manager.pool();
|
|
||||||
let csv = export_all_metrics_csv(pool, instance_filter).await
|
|
||||||
.map_err(|e| Status::internal(e.to_string()))?;
|
|
||||||
Ok(Response::new(ExportMetricsCsvResponse { csv }))
|
|
||||||
}
|
|
||||||
.instrument(info_span!("export_metrics_csv"))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default gRPC admin port.
|
/// Default gRPC admin port.
|
||||||
@ -278,185 +243,3 @@ pub fn spawn(
|
|||||||
let _ = shutdown_rx.recv().await;
|
let _ = shutdown_rx.recv().await;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Metrics helpers — mirror of observability::redis_metrics (daily hash format)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
use deadpool_redis::cluster::Pool;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Snapshot stored in Redis under `metrics:{instance}:{YYYY-MM-DD}` (hash).
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct MetricsSnapshot {
|
|
||||||
instance_id: String,
|
|
||||||
timestamp_secs: i64,
|
|
||||||
#[serde(flatten)]
|
|
||||||
http: std::collections::HashMap<String, serde_json::Value>,
|
|
||||||
#[serde(flatten)]
|
|
||||||
room: std::collections::HashMap<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Query metrics for all known instances, optionally filtered by `instance_filter`.
|
|
||||||
async fn query_all_instance_metrics(
|
|
||||||
pool: &Pool,
|
|
||||||
instance_filter: &str,
|
|
||||||
limit: usize,
|
|
||||||
) -> anyhow::Result<Vec<InstanceMetrics>> {
|
|
||||||
let mut conn = pool.get().await?;
|
|
||||||
|
|
||||||
// Scan for all daily index keys to extract instance ids
|
|
||||||
let mut cursor = 0u64;
|
|
||||||
let mut all_instance_ids = Vec::new();
|
|
||||||
let mut seen = std::collections::HashSet::new();
|
|
||||||
loop {
|
|
||||||
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
|
||||||
.arg(cursor)
|
|
||||||
.arg("MATCH")
|
|
||||||
.arg("metrics:index:*")
|
|
||||||
.arg("COUNT")
|
|
||||||
.arg(100)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
for k in keys {
|
|
||||||
// Key format: metrics:index:{instance}:{YYYY-MM-DD}
|
|
||||||
if let Some(rest) = k.strip_prefix("metrics:index:") {
|
|
||||||
if let Some((instance_id, _)) = rest.rsplit_once(':') {
|
|
||||||
if seen.insert(instance_id.to_string()) {
|
|
||||||
all_instance_ids.push(instance_id.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cursor = new_cursor;
|
|
||||||
if cursor == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut results = Vec::new();
|
|
||||||
for instance_id in all_instance_ids {
|
|
||||||
if !instance_filter.is_empty() && !instance_id.contains(instance_filter) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let snapshots = query_instance_metrics(pool, &instance_id, limit).await?;
|
|
||||||
for (_, payload) in snapshots {
|
|
||||||
results.push(InstanceMetrics {
|
|
||||||
instance_id: payload.instance_id,
|
|
||||||
timestamp_secs: payload.timestamp_secs,
|
|
||||||
http: payload
|
|
||||||
.http
|
|
||||||
.into_iter()
|
|
||||||
.map(|(k, v)| (k, v.to_string()))
|
|
||||||
.collect(),
|
|
||||||
room: payload
|
|
||||||
.room
|
|
||||||
.into_iter()
|
|
||||||
.map(|(k, v)| (k, v.to_string()))
|
|
||||||
.collect(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Query metrics for a single instance from Redis (daily hash buckets).
|
|
||||||
async fn query_instance_metrics(
|
|
||||||
pool: &Pool,
|
|
||||||
instance_id: &str,
|
|
||||||
limit: usize,
|
|
||||||
) -> anyhow::Result<Vec<(i64, MetricsSnapshot)>> {
|
|
||||||
let mut conn = pool.get().await?;
|
|
||||||
|
|
||||||
// Scan for all daily index keys for this instance
|
|
||||||
let index_pattern = format!("metrics:index:{}:*", instance_id);
|
|
||||||
let mut day_cursor = 0u64;
|
|
||||||
let mut day_keys: Vec<String> = Vec::new();
|
|
||||||
loop {
|
|
||||||
let (new_cursor, keys): (u64, Vec<String>) = redis::cmd("SCAN")
|
|
||||||
.arg(day_cursor)
|
|
||||||
.arg("MATCH")
|
|
||||||
.arg(&index_pattern)
|
|
||||||
.arg("COUNT")
|
|
||||||
.arg(100)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
day_keys.extend(keys);
|
|
||||||
day_cursor = new_cursor;
|
|
||||||
if day_cursor == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all timestamps from all daily indexes
|
|
||||||
let mut all_timestamps: Vec<i64> = Vec::new();
|
|
||||||
for day_key in &day_keys {
|
|
||||||
let timestamps: Vec<i64> = redis::cmd("ZREVRANGE")
|
|
||||||
.arg(day_key)
|
|
||||||
.arg(0)
|
|
||||||
.arg((limit as isize - 1).max(0))
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
all_timestamps.extend(timestamps);
|
|
||||||
}
|
|
||||||
|
|
||||||
all_timestamps.sort_by_key(|&ts| std::cmp::Reverse(ts));
|
|
||||||
all_timestamps.truncate(limit);
|
|
||||||
|
|
||||||
let mut results = Vec::with_capacity(all_timestamps.len());
|
|
||||||
for ts in &all_timestamps {
|
|
||||||
let dt = chrono::DateTime::from_timestamp(*ts, 0)
|
|
||||||
.unwrap_or_else(chrono::Utc::now);
|
|
||||||
let day_str = dt.format("%Y-%m-%d").to_string();
|
|
||||||
let hash_key = format!("metrics:{}:{}", instance_id, day_str);
|
|
||||||
|
|
||||||
let fields: Option<std::collections::HashMap<String, String>> = redis::cmd("HGETALL")
|
|
||||||
.arg(&hash_key)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(fields) = fields {
|
|
||||||
let mut http = std::collections::HashMap::new();
|
|
||||||
let mut room = std::collections::HashMap::new();
|
|
||||||
for (k, v) in &fields {
|
|
||||||
let json_val: serde_json::Value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.clone()));
|
|
||||||
if let Some(stripped) = k.strip_prefix("http_") {
|
|
||||||
http.insert(stripped.to_string(), json_val);
|
|
||||||
} else if let Some(stripped) = k.strip_prefix("room_") {
|
|
||||||
room.insert(stripped.to_string(), json_val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results.push((*ts, MetricsSnapshot {
|
|
||||||
instance_id: instance_id.to_string(),
|
|
||||||
timestamp_secs: *ts,
|
|
||||||
http,
|
|
||||||
room,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
results.sort_by_key(|(ts, _)| *ts);
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export all metrics as CSV.
|
|
||||||
async fn export_all_metrics_csv(pool: &Pool, instance_filter: &str) -> anyhow::Result<String> {
|
|
||||||
let instances = query_all_instance_metrics(pool, instance_filter, 1000).await?;
|
|
||||||
let mut rows = Vec::new();
|
|
||||||
for inst in instances {
|
|
||||||
let ts = inst.timestamp_secs;
|
|
||||||
let id = inst.instance_id;
|
|
||||||
for (k, v) in inst.http {
|
|
||||||
rows.push(format!("{},{},http_{},{}", id, ts, k, v));
|
|
||||||
}
|
|
||||||
for (k, v) in inst.room {
|
|
||||||
rows.push(format!("{},{},room_{},{}", id, ts, k, v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let header = "instance_id,timestamp,metric,value";
|
|
||||||
if rows.is_empty() {
|
|
||||||
return Ok(header.to_string());
|
|
||||||
}
|
|
||||||
Ok(format!("{}\n{}", header, rows.join("\n")))
|
|
||||||
}
|
|
||||||
|
|||||||
@ -154,6 +154,7 @@ impl AppService {
|
|||||||
let room_metrics = Arc::new(RoomMetrics::default());
|
let room_metrics = Arc::new(RoomMetrics::default());
|
||||||
let room_manager = Arc::new(room::connection::RoomConnectionManager::new(
|
let room_manager = Arc::new(room::connection::RoomConnectionManager::new(
|
||||||
room_metrics.clone(),
|
room_metrics.clone(),
|
||||||
|
cache.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let redis_url = config
|
let redis_url = config
|
||||||
|
|||||||
@ -236,15 +236,28 @@ impl SessionStorage {
|
|||||||
.await
|
.await
|
||||||
.map_err(Self::to_err)?;
|
.map_err(Self::to_err)?;
|
||||||
|
|
||||||
let mut sessions = Vec::new();
|
if session_ids.is_empty() {
|
||||||
for id_str in &session_ids {
|
return Ok(Vec::new());
|
||||||
if let Ok(sid) = Uuid::parse_str(id_str) {
|
|
||||||
if let Ok(Some(session)) = self.get_session(&sid).await {
|
|
||||||
sessions.push(session);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch fetch all sessions in a single MGET instead of N individual GET calls.
|
||||||
|
let keys: Vec<String> = session_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| format!("{}{}", KEY_CONN, id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let values: Vec<Option<String>> = redis::cmd("MGET")
|
||||||
|
.arg(&keys)
|
||||||
|
.query_async(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(Self::to_err)?;
|
||||||
|
|
||||||
|
let sessions: Vec<UserSession> = values
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|v| serde_json::from_str(&v).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(sessions)
|
Ok(sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,15 +274,28 @@ impl SessionStorage {
|
|||||||
.await
|
.await
|
||||||
.map_err(Self::to_err)?;
|
.map_err(Self::to_err)?;
|
||||||
|
|
||||||
let mut sessions = Vec::new();
|
if session_ids.is_empty() {
|
||||||
for id_str in &session_ids {
|
return Ok(Vec::new());
|
||||||
if let Ok(sid) = Uuid::parse_str(id_str) {
|
|
||||||
if let Ok(Some(session)) = self.get_session(&sid).await {
|
|
||||||
sessions.push(session);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Batch fetch all sessions in a single MGET instead of N individual GET calls.
|
||||||
|
let keys: Vec<String> = session_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| format!("{}{}", KEY_CONN, id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let values: Vec<Option<String>> = redis::cmd("MGET")
|
||||||
|
.arg(&keys)
|
||||||
|
.query_async(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(Self::to_err)?;
|
||||||
|
|
||||||
|
let sessions: Vec<UserSession> = values
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|v| serde_json::from_str(&v).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(sessions)
|
Ok(sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
useDroppable,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
type UniqueIdentifier,
|
type UniqueIdentifier,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
@ -141,6 +142,9 @@ const ChannelGroup = memo(function ChannelGroup({
|
|||||||
}) {
|
}) {
|
||||||
const ids: UniqueIdentifier[] = rooms.map((r) => `${DRAG_PREFIX}${r.id}`);
|
const ids: UniqueIdentifier[] = rooms.map((r) => `${DRAG_PREFIX}${r.id}`);
|
||||||
|
|
||||||
|
// Make the category header a droppable zone so rooms can be dragged onto it
|
||||||
|
const { setNodeRef: setHeaderRef, isOver: isOverHeader } = useDroppable({ id: categoryName });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="discord-channel-category"
|
className="discord-channel-category"
|
||||||
@ -148,7 +152,8 @@ const ChannelGroup = memo(function ChannelGroup({
|
|||||||
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
|
onDrop={canReceiveDrops ? () => undefined /* handled by DnD */ : undefined}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed')}
|
ref={setHeaderRef}
|
||||||
|
className={cn('discord-channel-category-header w-full', isCollapsed && 'collapsed', isOverHeader && 'ring-1 ring-accent')}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||||
>
|
>
|
||||||
@ -309,25 +314,30 @@ export const DiscordChannelSidebar = memo(function DiscordChannelSidebar({
|
|||||||
[onMoveRoomToCategory],
|
[onMoveRoomToCategory],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group rooms by category
|
// Group rooms by category — empty categories still show as collapsible groups
|
||||||
const uncategorized = useMemo(
|
const uncategorized = useMemo(
|
||||||
() => rooms.filter((r) => !r.category_info?.name),
|
() => rooms.filter((r) => !r.category),
|
||||||
[rooms],
|
[rooms],
|
||||||
);
|
);
|
||||||
const categorized = useMemo(
|
const categorized = useMemo(
|
||||||
() => rooms.filter((r) => r.category_info?.name),
|
() => rooms.filter((r) => r.category),
|
||||||
[rooms],
|
[rooms],
|
||||||
);
|
);
|
||||||
|
|
||||||
const categoryMap = useMemo(() => {
|
const categoryMap = useMemo(() => {
|
||||||
|
// Start with ALL categories (including empty ones), then merge in rooms
|
||||||
const map = new Map<CatName, RoomWithCategory[]>();
|
const map = new Map<CatName, RoomWithCategory[]>();
|
||||||
|
for (const cat of categories) {
|
||||||
|
if (!map.has(cat.name)) map.set(cat.name, []);
|
||||||
|
}
|
||||||
for (const room of categorized) {
|
for (const room of categorized) {
|
||||||
const name = room.category_info!.name;
|
const catName = room.category_info?.name;
|
||||||
if (!map.has(name)) map.set(name, []);
|
if (catName && map.has(catName)) {
|
||||||
map.get(name)!.push(room);
|
map.get(catName)!.push(room);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [categorized]);
|
}, [categorized, categories]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="discord-channel-sidebar flex flex-col h-full">
|
<div className="discord-channel-sidebar flex flex-col h-full">
|
||||||
|
|||||||
@ -59,6 +59,7 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
|||||||
refreshThreads,
|
refreshThreads,
|
||||||
roomAiConfigs,
|
roomAiConfigs,
|
||||||
presence,
|
presence,
|
||||||
|
typingUsers,
|
||||||
} = useRoom();
|
} = useRoom();
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
@ -352,6 +353,35 @@ export function DiscordChatPanel({ room, isAdmin, onClose, onDelete, onToggleCha
|
|||||||
onCreateThread={handleCreateThread}
|
onCreateThread={handleCreateThread}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Typing indicator — show who is typing */}
|
||||||
|
{(() => {
|
||||||
|
const roomTyping = typingUsers?.[room.id] ?? {};
|
||||||
|
const typingList = Object.entries(roomTyping);
|
||||||
|
if (typingList.length === 0) return null;
|
||||||
|
const names = typingList.map(([, v]) => v.username);
|
||||||
|
const label = names.length === 1
|
||||||
|
? `${names[0]} is typing...`
|
||||||
|
: names.length === 2
|
||||||
|
? `${names[0]} and ${names[1]} are typing...`
|
||||||
|
: `${names[0]} and ${names.length - 1} others are typing...`;
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-1 text-xs text-muted-foreground animate-pulse flex items-center gap-1.5">
|
||||||
|
<span className="flex gap-0.5">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="w-1.5 h-1.5 rounded-full bg-muted-foreground"
|
||||||
|
style={{
|
||||||
|
animation: `typing-bounce 1.2s infinite ${i * 0.2}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<MessageInput
|
<MessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
roomName={room.room_name ?? 'room'}
|
roomName={room.room_name ?? 'room'}
|
||||||
|
|||||||
@ -13,6 +13,8 @@ import type { MessageWithMeta } from '@/contexts';
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '😮'];
|
||||||
|
|
||||||
interface MessageActionsProps {
|
interface MessageActionsProps {
|
||||||
message: MessageWithMeta;
|
message: MessageWithMeta;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
@ -52,7 +54,26 @@ export function MessageActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
{/* Add reaction */}
|
{/* Quick reaction bar — Slack-style hover reveal */}
|
||||||
|
{QUICK_EMOJIS.map((emoji) => {
|
||||||
|
const reacted = message.reactions?.find((r) => r.emoji === emoji)?.reacted_by_me;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={emoji}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleReaction(emoji)}
|
||||||
|
className={`size-6 p-0 text-sm hover:bg-accent transition-transform hover:scale-125 ${
|
||||||
|
reacted ? 'bg-accent' : ''
|
||||||
|
}`}
|
||||||
|
title={emoji}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add more reaction — opens full picker */}
|
||||||
<Popover open={reactionPickerOpen} onOpenChange={setReactionPickerOpen}>
|
<Popover open={reactionPickerOpen} onOpenChange={setReactionPickerOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export const MessageBubble = memo(function MessageBubble({
|
|||||||
onOpenUserCard,
|
onOpenUserCard,
|
||||||
onOpenThread,
|
onOpenThread,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const [showFullText, setShowFullText] = useState(false);
|
const [showFullText, setShowFullText] = useState(true); // default expanded
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editContent, setEditContent] = useState(message.content);
|
const [editContent, setEditContent] = useState(message.content);
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
|
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
|
||||||
import { IMEditor } from './editor/IMEditor';
|
import { IMEditor } from './editor/IMEditor';
|
||||||
import { useRoom } from '@/contexts';
|
import { useRoom } from '@/contexts';
|
||||||
import type { MessageAST } from './editor/types';
|
import type { MessageAST, EditorNode } from './editor/types';
|
||||||
import type { IMEditorHandle } from './editor/IMEditor';
|
import type { IMEditorHandle } from './editor/IMEditor';
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
@ -26,11 +26,52 @@ export interface MessageInputHandle {
|
|||||||
getAttachmentIds: () => string[];
|
getAttachmentIds: () => string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slash commands available in the editor
|
||||||
|
const SLASH_COMMANDS = [
|
||||||
|
{ id: 'ai', label: '/ai', description: 'Ask AI a question', type: 'command' as const },
|
||||||
|
{ id: 'remind', label: '/remind', description: 'Set a reminder (e.g. /remind 10m Check CI)', type: 'command' as const },
|
||||||
|
{ id: 'poll', label: '/poll', description: 'Create a poll (e.g. /poll "Question?" A B C)', type: 'command' as const },
|
||||||
|
{ id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Special mention items — @here (online), @channel (all members)
|
||||||
|
const SPECIAL_MENTIONS = [
|
||||||
|
{
|
||||||
|
id: '__here__',
|
||||||
|
label: 'here',
|
||||||
|
description: 'Notify online members',
|
||||||
|
type: 'special_here' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '__channel__',
|
||||||
|
label: 'channel',
|
||||||
|
description: 'Notify all members',
|
||||||
|
type: 'special_channel' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Serialize tiptap AST to backend-parseable string format. */
|
||||||
|
function serializeMessageAst(ast: MessageAST): string {
|
||||||
|
return ast.content.map(serializeNode).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeNode(node: EditorNode): string {
|
||||||
|
if (node.type === 'text') return node.text;
|
||||||
|
if (node.type === 'mention') return `@[${node.attrs.type}:${node.attrs.id}:${node.attrs.label}]`;
|
||||||
|
if (node.type === 'hardBreak') return '\n';
|
||||||
|
if (node.type === 'file') return ''; // files are sent separately via attachmentIds
|
||||||
|
if (node.type === 'emoji') return `[emoji:${node.attrs.name}]`;
|
||||||
|
// Recurse into container nodes (paragraph, bulletList, etc.)
|
||||||
|
const children = (node as any).content as EditorNode[] | undefined;
|
||||||
|
if (children) return children.map(serializeNode).join('');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
||||||
{ roomName, onSend, replyingTo, onCancelReply },
|
{ roomName, onSend, replyingTo, onCancelReply },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const { members, activeRoomId } = useRoom();
|
const { members, activeRoomId, roomAiConfigs } = useRoom();
|
||||||
|
|
||||||
// Ref passed to the inner IMEditor
|
// Ref passed to the inner IMEditor
|
||||||
const innerEditorRef = useRef<IMEditorHandle | null>(null);
|
const innerEditorRef = useRef<IMEditorHandle | null>(null);
|
||||||
@ -45,14 +86,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
|
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
// Slash commands available in the editor
|
|
||||||
const SLASH_COMMANDS = [
|
|
||||||
{ id: 'ai', label: '/ai', description: 'Ask AI a question', type: 'command' as const },
|
|
||||||
{ id: 'remind', label: '/remind', description: 'Set a reminder (e.g. /remind 10m Check CI)', type: 'command' as const },
|
|
||||||
{ id: 'poll', label: '/poll', description: 'Create a poll (e.g. /poll "Question?" A B C)', type: 'command' as const },
|
|
||||||
{ id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation
|
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation
|
||||||
const mentionItems = useMemo(() => ({
|
const mentionItems = useMemo(() => ({
|
||||||
users: members.map((m) => ({
|
users: members.map((m) => ({
|
||||||
@ -62,9 +95,14 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
avatar: m.user_info?.avatar_url ?? undefined,
|
avatar: m.user_info?.avatar_url ?? undefined,
|
||||||
})),
|
})),
|
||||||
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
|
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
|
||||||
ai: [] as { id: string; label: string; type: 'ai'; avatar?: string }[],
|
ai: roomAiConfigs.map((cfg) => ({
|
||||||
|
id: cfg.model,
|
||||||
|
label: cfg.modelName ?? cfg.model,
|
||||||
|
type: 'ai' as const,
|
||||||
|
})),
|
||||||
commands: SLASH_COMMANDS,
|
commands: SLASH_COMMANDS,
|
||||||
}), [members]);
|
specialMentions: SPECIAL_MENTIONS,
|
||||||
|
}), [members, roomAiConfigs]);
|
||||||
|
|
||||||
// File upload handler — POST to /rooms/{room_id}/upload
|
// File upload handler — POST to /rooms/{room_id}/upload
|
||||||
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
|
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
|
||||||
@ -77,9 +115,10 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
// onSend: extract plain text from MessageAST for sending
|
// onSend: serialize AST to backend-parseable format
|
||||||
const handleSend = (text: string, _ast: MessageAST) => {
|
const handleSend = (_text: string, ast: MessageAST) => {
|
||||||
onSend(text);
|
const serialized = serializeMessageAst(ast);
|
||||||
|
onSend(serialized);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -108,10 +108,13 @@ export const MessageList = memo(function MessageList({
|
|||||||
const result: MessageRow[] = [];
|
const result: MessageRow[] = [];
|
||||||
let lastDateKey: string | null = null;
|
let lastDateKey: string | null = null;
|
||||||
let lastSenderKey: string | null = null;
|
let lastSenderKey: string | null = null;
|
||||||
|
let lastMessageTime: number | null = null;
|
||||||
|
const GROUP_GAP_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
const dateKey = getDateKey(message.send_at);
|
const dateKey = getDateKey(message.send_at);
|
||||||
const senderKey = getSenderKey(message);
|
const senderKey = getSenderKey(message);
|
||||||
|
const msgTime = new Date(message.send_at).getTime();
|
||||||
|
|
||||||
if (dateKey !== lastDateKey) {
|
if (dateKey !== lastDateKey) {
|
||||||
result.push({
|
result.push({
|
||||||
@ -121,9 +124,14 @@ export const MessageList = memo(function MessageList({
|
|||||||
});
|
});
|
||||||
lastDateKey = dateKey;
|
lastDateKey = dateKey;
|
||||||
lastSenderKey = null;
|
lastSenderKey = null;
|
||||||
|
lastMessageTime = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const grouped = senderKey === lastSenderKey;
|
// Group if: same sender AND within 5-minute gap (Discord-style)
|
||||||
|
const sameSender = senderKey === lastSenderKey;
|
||||||
|
const withinTimeGap = lastMessageTime !== null && (msgTime - lastMessageTime) < GROUP_GAP_MS;
|
||||||
|
const grouped = sameSender && withinTimeGap;
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
message,
|
message,
|
||||||
@ -132,6 +140,7 @@ export const MessageList = memo(function MessageList({
|
|||||||
key: message.id,
|
key: message.id,
|
||||||
});
|
});
|
||||||
lastSenderKey = senderKey;
|
lastSenderKey = senderKey;
|
||||||
|
lastMessageTime = msgTime;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [messages, replyMap]);
|
}, [messages, replyMap]);
|
||||||
|
|||||||
@ -5,12 +5,13 @@
|
|||||||
* Colors: Clean modern palette, no Discord reference
|
* Colors: Clean modern palette, no Discord reference
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
|
import {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
|
||||||
import {EditorContent, Extension, useEditor} from '@tiptap/react';
|
import {EditorContent, Extension, useEditor} from '@tiptap/react';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import {CustomEmojiNode} from './EmojiNode';
|
import {CustomEmojiNode} from './EmojiNode';
|
||||||
import type {MentionItem, MentionType, MessageAST} from './types';
|
import {MentionNodeType} from './MentionNode';
|
||||||
|
import type {EditorNode, MentionItem, MentionType, MessageAST} from './types';
|
||||||
import {Paperclip, Send, Smile, X} from 'lucide-react';
|
import {Paperclip, Send, Smile, X} from 'lucide-react';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {COMMON_EMOJIS} from '../../shared';
|
import {COMMON_EMOJIS} from '../../shared';
|
||||||
@ -26,6 +27,7 @@ export interface IMEditorProps {
|
|||||||
channels: MentionItem[];
|
channels: MentionItem[];
|
||||||
ai: MentionItem[];
|
ai: MentionItem[];
|
||||||
commands: MentionItem[];
|
commands: MentionItem[];
|
||||||
|
specialMentions?: MentionItem[];
|
||||||
};
|
};
|
||||||
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
|
onUploadFile?: (file: File) => Promise<{ id: string; url: string }>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
@ -137,30 +139,6 @@ function EmojiPicker({onClose, onSelect, p}: { onClose: () => void; onSelect: (e
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Keyboard Extension ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const KeyboardSend = Extension.create({
|
|
||||||
name: 'keyboardSend',
|
|
||||||
addKeyboardShortcuts() {
|
|
||||||
return {
|
|
||||||
Enter: ({editor}) => {
|
|
||||||
if (editor.isEmpty) return true;
|
|
||||||
const text = editor.getText().trim();
|
|
||||||
if (!text) return true;
|
|
||||||
(editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
'Shift-Enter': ({editor}) => {
|
|
||||||
editor.chain().focus().setHardBreak().run();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
addStorage() {
|
|
||||||
return {onSend: null as ((t: string, a: MessageAST) => void) | null};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function filterMentionItems(all: MentionItem[], q: string): MentionItem[] {
|
function filterMentionItems(all: MentionItem[], q: string): MentionItem[] {
|
||||||
@ -174,20 +152,77 @@ function getBadge(type: MentionType): { label: string; cls: string } | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Mention Dropdown ────────────────────────────────────────────────────────
|
/** Serialize tiptap AST to backend-parseable string. */
|
||||||
|
function serializeAstForSend(ast: MessageAST): string {
|
||||||
|
return ast.content.map(serializeAstNode).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAstNode(node: EditorNode): string {
|
||||||
|
if (node.type === 'text') return node.text;
|
||||||
|
if (node.type === 'mention') return `@[${node.attrs.type}:${node.attrs.id}:${node.attrs.label}]`;
|
||||||
|
if (node.type === 'hardBreak') return '\n';
|
||||||
|
if (node.type === 'emoji') return `[emoji:${node.attrs.name}]`;
|
||||||
|
if (node.type === 'file') return '';
|
||||||
|
// Recurse into container nodes (paragraph, bulletList, etc.)
|
||||||
|
const children = (node as any).content as EditorNode[] | undefined;
|
||||||
|
if (children) return children.map(serializeAstNode).join('');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mention Dropdown (sectioned by type) ────────────────────────────────────
|
||||||
|
|
||||||
|
const SECTION_ORDER = ['special_here', 'special_channel', 'ai', 'user', 'channel', 'command'] as const;
|
||||||
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
|
special_here: 'Notify',
|
||||||
|
special_channel: 'Notify',
|
||||||
|
ai: 'AI',
|
||||||
|
user: 'Members',
|
||||||
|
channel: 'Channels',
|
||||||
|
command: 'Commands',
|
||||||
|
};
|
||||||
|
const SPECIAL_TYPES = ['special_here', 'special_channel'];
|
||||||
|
|
||||||
function MentionDropdown({
|
function MentionDropdown({
|
||||||
items, selectedIndex, onSelect, p, query,
|
items, selectedIndex, onSelect, p, query,
|
||||||
}: {
|
}: {
|
||||||
items: MentionItem[];
|
items: MentionItem[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (item: MentionItem) => void;
|
onSelect: (item: MentionItem) => void;
|
||||||
p: Palette;
|
p: Palette;
|
||||||
query: string;
|
query: string;
|
||||||
}) {
|
}) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Auto-scroll selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
const selectedEl = scrollRef.current.querySelector(`[data-mention-idx="${selectedIndex}"]`);
|
||||||
|
if (selectedEl) {
|
||||||
|
selectedEl.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}, [selectedIndex]);
|
||||||
|
// Group items by section
|
||||||
|
const sections: Map<string, MentionItem[]> = new Map();
|
||||||
|
for (const sectionType of SECTION_ORDER) {
|
||||||
|
const sectionItems = items.filter(
|
||||||
|
(item) => sectionType === 'special_here' || sectionType === 'special_channel'
|
||||||
|
? item.type === sectionType
|
||||||
|
: SPECIAL_TYPES.includes(sectionType)
|
||||||
|
? false
|
||||||
|
: item.type === sectionType,
|
||||||
|
);
|
||||||
|
if (sectionItems.length > 0) {
|
||||||
|
sections.set(sectionType, sectionItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build flat index map: item → its position in the overall items array
|
||||||
|
const flatIndexMap = new Map<MentionItem, number>();
|
||||||
|
items.forEach((item, i) => flatIndexMap.set(item, i));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 z-50 overflow-hidden"
|
className="absolute bottom-full left-0 mb-1 z-50 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
background: p.popupBg,
|
background: p.popupBg,
|
||||||
border: `1px solid ${p.popupBorder}`,
|
border: `1px solid ${p.popupBorder}`,
|
||||||
@ -202,15 +237,52 @@ function MentionDropdown({
|
|||||||
No results for “{query}”
|
No results for “{query}”
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1 max-h-60 overflow-y-auto">
|
<div className="py-1 max-h-60 overflow-y-auto" ref={scrollRef}>
|
||||||
{items.map((item, i) => {
|
{Array.from(sections.entries()).map(([sectionType, sectionItems], si) => (
|
||||||
|
<div key={sectionType}>
|
||||||
|
{/* Section header — only show if there are other sections after or before */}
|
||||||
|
{sections.size > 1 && (
|
||||||
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide" style={{color: p.textSubtle}}>
|
||||||
|
{SECTION_LABELS[sectionType]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sectionItems.map((item) => {
|
||||||
|
const realIndex = flatIndexMap.get(item) ?? 0;
|
||||||
|
const isSpecial = SPECIAL_TYPES.includes(item.type);
|
||||||
|
const icon = item.type === 'special_here' ? '📍' : item.type === 'special_channel' ? '📢' : undefined;
|
||||||
|
|
||||||
|
if (isSpecial) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
data-mention-idx={realIndex}
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
||||||
|
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
|
||||||
|
>
|
||||||
|
<span className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-base">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
|
||||||
|
@{item.label}
|
||||||
|
</span>
|
||||||
|
{item.description && (
|
||||||
|
<span className="text-[10px] text-muted-foreground mr-1">
|
||||||
|
{item.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const badge = getBadge(item.type);
|
const badge = getBadge(item.type);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
data-mention-idx={realIndex}
|
||||||
onClick={() => onSelect(item)}
|
onClick={() => onSelect(item)}
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
||||||
style={{background: i === selectedIndex ? p.popupSelected : 'transparent'}}
|
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
|
||||||
>
|
>
|
||||||
{item.avatar ? (
|
{item.avatar ? (
|
||||||
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0"/>
|
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0"/>
|
||||||
@ -234,6 +306,12 @@ function MentionDropdown({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{/* Divider between sections */}
|
||||||
|
{si < sections.size - 1 && (
|
||||||
|
<div className="mx-3 my-1 border-t border-border" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -258,25 +336,77 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
const [, setMentionPos] = useState({top: 0, left: 0});
|
const [, setMentionPos] = useState({top: 0, left: 0});
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
|
// Refs for keyboard shortcut closures (tiptap can't read React state directly)
|
||||||
|
const mentionOpenRef = useRef(false);
|
||||||
|
const mentionIdxRef = useRef(0);
|
||||||
|
const mentionItemsRef = useRef<MentionItem[]>([]);
|
||||||
|
const editorRef = useRef<ReturnType<typeof useEditor>>(null);
|
||||||
|
|
||||||
|
// Sync refs with state
|
||||||
|
useEffect(() => { mentionOpenRef.current = mentionOpen; }, [mentionOpen]);
|
||||||
|
useEffect(() => { mentionIdxRef.current = mentionIdx; }, [mentionIdx]);
|
||||||
|
useEffect(() => { mentionItemsRef.current = mentionItems2; }, [mentionItems2]);
|
||||||
|
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const allItems = [
|
// Candidate pools by trigger character
|
||||||
...mentionItems.users,
|
const atPool = useMemo(() => [
|
||||||
...mentionItems.channels,
|
...(mentionItems.specialMentions ?? []),
|
||||||
...mentionItems.ai,
|
...mentionItems.ai,
|
||||||
...mentionItems.commands,
|
...mentionItems.users,
|
||||||
];
|
], [mentionItems.specialMentions, mentionItems.ai, mentionItems.users]);
|
||||||
|
|
||||||
|
const hashPool = useMemo(() => [...mentionItems.channels], [mentionItems.channels]);
|
||||||
|
|
||||||
|
const slashPool = useMemo(() => [...mentionItems.commands], [mentionItems.commands]);
|
||||||
|
|
||||||
const selectMention = useCallback((item: MentionItem) => {
|
const selectMention = useCallback((item: MentionItem) => {
|
||||||
|
const editor = editorRef.current;
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
if (item.type === 'command') {
|
|
||||||
// Replace the / prefix with the full command label
|
// Delete the trigger + query text first, then insert the mention node
|
||||||
editor.chain().focus().insertContent(item.label + ' ').run();
|
const text = editor.getText();
|
||||||
} else {
|
const {from} = editor.state.selection;
|
||||||
// Use backend-parseable format: @[type:id:label]
|
let triggerStart = from;
|
||||||
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
|
for (let i = from - 1; i >= 1; i--) {
|
||||||
editor.chain().focus().insertContent(mentionStr).run();
|
const c = text[i - 1];
|
||||||
|
if (c === '@' || c === '#' || c === '/') {
|
||||||
|
triggerStart = i;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
if (/\s/.test(c)) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from triggerStart-1 to from (the @query / #query / /query text)
|
||||||
|
if (triggerStart < from) {
|
||||||
|
editor.chain().focus().deleteRange({from: triggerStart - 1, to: from}).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert mention node
|
||||||
|
editor.chain().focus().insertContent({
|
||||||
|
type: 'mention',
|
||||||
|
attrs: { id: item.id, label: item.label, type: item.type },
|
||||||
|
}).insertContent(' ').run();
|
||||||
|
|
||||||
|
setMentionOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const moveMentionIdx = useCallback((delta: number) => {
|
||||||
|
const len = mentionItemsRef.current.length;
|
||||||
|
if (len === 0) return;
|
||||||
|
const next = (mentionIdxRef.current + delta + len) % len;
|
||||||
|
setMentionIdx(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectCurrentMention = useCallback(() => {
|
||||||
|
const items = mentionItemsRef.current;
|
||||||
|
const idx = mentionIdxRef.current;
|
||||||
|
if (items[idx]) {
|
||||||
|
selectMention(items[idx]);
|
||||||
|
}
|
||||||
|
}, [selectMention]);
|
||||||
|
|
||||||
|
const closeMention = useCallback(() => {
|
||||||
setMentionOpen(false);
|
setMentionOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -285,7 +415,62 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
StarterKit.configure({undoRedo: {depth: 100}}),
|
StarterKit.configure({undoRedo: {depth: 100}}),
|
||||||
Placeholder.configure({placeholder}),
|
Placeholder.configure({placeholder}),
|
||||||
CustomEmojiNode,
|
CustomEmojiNode,
|
||||||
KeyboardSend,
|
MentionNodeType,
|
||||||
|
Extension.create({
|
||||||
|
name: 'mentionKeyboard',
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
Enter: () => {
|
||||||
|
if (mentionOpenRef.current) {
|
||||||
|
selectCurrentMention();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const ed = editorRef.current;
|
||||||
|
if (!ed) return true;
|
||||||
|
const ast = ed.getJSON() as MessageAST;
|
||||||
|
const serialized = serializeAstForSend(ast);
|
||||||
|
if (!serialized.trim()) return true;
|
||||||
|
(ed.storage as any).mentionKeyboard?.onSend?.(serialized, ast);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
'Shift-Enter': ({editor: ed}) => {
|
||||||
|
ed.chain().focus().setHardBreak().run();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
ArrowUp: () => {
|
||||||
|
if (mentionOpenRef.current) {
|
||||||
|
moveMentionIdx(-1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
ArrowDown: () => {
|
||||||
|
if (mentionOpenRef.current) {
|
||||||
|
moveMentionIdx(1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
Escape: () => {
|
||||||
|
if (mentionOpenRef.current) {
|
||||||
|
closeMention();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
Tab: () => {
|
||||||
|
if (mentionOpenRef.current) {
|
||||||
|
selectCurrentMention();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addStorage() {
|
||||||
|
return {onSend: null as ((t: string, a: MessageAST) => void) | null};
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
editorProps: {
|
editorProps: {
|
||||||
handlePaste: (_v, e) => {
|
handlePaste: (_v, e) => {
|
||||||
@ -311,23 +496,27 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
const text = ed.getText();
|
const text = ed.getText();
|
||||||
const {from} = ed.state.selection;
|
const {from} = ed.state.selection;
|
||||||
|
|
||||||
|
// Backward scan from cursor to find trigger character
|
||||||
let ts = from;
|
let ts = from;
|
||||||
|
let trigger: string | null = null;
|
||||||
for (let i = from - 1; i >= 1; i--) {
|
for (let i = from - 1; i >= 1; i--) {
|
||||||
const c = text[i - 1];
|
const c = text[i - 1];
|
||||||
if (c === '@' || c === '/') {
|
if (c === '@' || c === '#' || c === '/') {
|
||||||
ts = i;
|
ts = i;
|
||||||
|
trigger = c;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (/\s/.test(c)) break;
|
if (/\s/.test(c)) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = text.slice(ts - 1, from);
|
const q = text.slice(ts - 1, from);
|
||||||
|
|
||||||
if (q.startsWith('@') && q.length > 1) {
|
if (trigger === '@' && q.length >= 1) {
|
||||||
const results = filterMentionItems(allItems, q.slice(1));
|
const results = filterMentionItems(atPool, q.slice(1));
|
||||||
setMentionQuery(q.slice(1));
|
setMentionQuery(q.slice(1));
|
||||||
setMentionItems2(results);
|
setMentionItems2(results);
|
||||||
setMentionIdx(0);
|
setMentionIdx(0);
|
||||||
setMentionOpen(true);
|
setMentionOpen(results.length > 0);
|
||||||
|
|
||||||
if (wrapRef.current) {
|
if (wrapRef.current) {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
@ -337,13 +526,27 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)});
|
setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (q.startsWith('/') && q.length > 1) {
|
} else if (trigger === '#' && q.length >= 1) {
|
||||||
// Filter commands by query (e.g. "/ai" matches "ai")
|
const results = filterMentionItems(hashPool, q.slice(1));
|
||||||
const results = filterMentionItems(mentionItems.commands, q.slice(1));
|
|
||||||
setMentionQuery(q.slice(1));
|
setMentionQuery(q.slice(1));
|
||||||
setMentionItems2(results);
|
setMentionItems2(results);
|
||||||
setMentionIdx(0);
|
setMentionIdx(0);
|
||||||
setMentionOpen(true);
|
setMentionOpen(results.length > 0);
|
||||||
|
|
||||||
|
if (wrapRef.current) {
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (sel?.rangeCount) {
|
||||||
|
const r = sel.getRangeAt(0).getBoundingClientRect();
|
||||||
|
const cr = wrapRef.current.getBoundingClientRect();
|
||||||
|
setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (trigger === '/' && q.length >= 1) {
|
||||||
|
const results = filterMentionItems(slashPool, q.slice(1));
|
||||||
|
setMentionQuery(q.slice(1));
|
||||||
|
setMentionItems2(results);
|
||||||
|
setMentionIdx(0);
|
||||||
|
setMentionOpen(results.length > 0);
|
||||||
|
|
||||||
if (wrapRef.current) {
|
if (wrapRef.current) {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
@ -361,8 +564,13 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
onBlur: () => setFocused(false),
|
onBlur: () => setFocused(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store editor ref
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor) (editor.storage as any).keyboardSend = {onSend};
|
editorRef.current = editor;
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor) (editor.storage as any).mentionKeyboard = {onSend};
|
||||||
}, [editor, onSend]);
|
}, [editor, onSend]);
|
||||||
|
|
||||||
const doUpload = async (file: File) => {
|
const doUpload = async (file: File) => {
|
||||||
@ -392,21 +600,26 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const send = () => {
|
const send = () => {
|
||||||
if (!editor || editor.isEmpty) return;
|
if (!editor) return;
|
||||||
const text = editor.getText().trim();
|
const ast = editor.getJSON() as MessageAST;
|
||||||
if (!text) return;
|
const serialized = serializeAstForSend(ast);
|
||||||
onSend(text, editor.getJSON() as MessageAST);
|
if (!serialized.trim()) return;
|
||||||
|
onSend(serialized, ast);
|
||||||
editor.commands.clearContent();
|
editor.commands.clearContent();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasContent = !!editor && editor.state.doc.content.size > 2;
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => editor?.commands.focus(),
|
focus: () => editor?.commands.focus(),
|
||||||
clearContent: () => editor?.commands.clearContent(),
|
clearContent: () => editor?.commands.clearContent(),
|
||||||
getContent: () => editor?.getText() ?? '',
|
getContent: () => editor?.getText() ?? '',
|
||||||
insertMention: (type: string, id: string, label: string) => {
|
insertMention: (type: string, id: string, label: string) => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const mentionStr = `@[${type}:${id}:${label}] `;
|
editor.chain().focus().insertContent({
|
||||||
editor.chain().focus().insertContent(mentionStr).run();
|
type: 'mention',
|
||||||
|
attrs: { id, label, type },
|
||||||
|
}).insertContent(' ').run();
|
||||||
},
|
},
|
||||||
getAttachmentIds: () => {
|
getAttachmentIds: () => {
|
||||||
if (!editor) return [];
|
if (!editor) return [];
|
||||||
@ -425,7 +638,6 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const hasContent = !!editor && !editor.isEmpty;
|
|
||||||
|
|
||||||
// Dynamic styles
|
// Dynamic styles
|
||||||
const borderColor = focused ? p.borderFocus : p.border;
|
const borderColor = focused ? p.borderFocus : p.border;
|
||||||
@ -464,6 +676,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div
|
<div
|
||||||
|
className="relative"
|
||||||
onClick={() => editor?.commands.focus()}
|
onClick={() => editor?.commands.focus()}
|
||||||
style={{
|
style={{
|
||||||
background: p.bg,
|
background: p.bg,
|
||||||
|
|||||||
36
src/components/room/message/editor/MentionNode.tsx
Normal file
36
src/components/room/message/editor/MentionNode.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* TipTap Mention Node — inline atom for @mentions, #channels, /commands.
|
||||||
|
* Renders via MentionView React NodeView with type-specific coloring.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
|
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import MentionView from './MentionView';
|
||||||
|
|
||||||
|
export const MentionNodeType = Node.create({
|
||||||
|
name: 'mention',
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
selectable: true,
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
id: { default: null },
|
||||||
|
label: { default: null },
|
||||||
|
type: { default: 'user' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'span[data-mention]' }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['span', mergeAttributes({ 'data-mention': '' }, HTMLAttributes)];
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(MentionView);
|
||||||
|
},
|
||||||
|
});
|
||||||
37
src/components/room/message/editor/MentionView.tsx
Normal file
37
src/components/room/message/editor/MentionView.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* React NodeView for TipTap mention nodes.
|
||||||
|
* Renders colored inline labels by mention type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNodeViewProps } from '@tiptap/react';
|
||||||
|
import { NodeViewWrapper } from '@tiptap/react';
|
||||||
|
|
||||||
|
const TYPE_STYLE: Record<string, { bg: string; text: string; prefix: string }> = {
|
||||||
|
user: { bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-700 dark:text-blue-300', prefix: '@' },
|
||||||
|
ai: { bg: 'bg-indigo-100 dark:bg-indigo-900/40', text: 'text-indigo-700 dark:text-indigo-300', prefix: '@' },
|
||||||
|
channel: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-600 dark:text-gray-400', prefix: '#' },
|
||||||
|
special_here: { bg: 'bg-orange-100 dark:bg-orange-900/40', text: 'text-orange-700 dark:text-orange-300', prefix: '@' },
|
||||||
|
special_channel: { bg: 'bg-orange-100 dark:bg-orange-900/40', text: 'text-orange-700 dark:text-orange-300', prefix: '@' },
|
||||||
|
command: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', prefix: '/' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MentionView(props: ReactNodeViewProps) {
|
||||||
|
const attrs = props.node.attrs as Record<string, string>;
|
||||||
|
const label = attrs.label ?? '';
|
||||||
|
const type = attrs.type ?? 'user';
|
||||||
|
const style = TYPE_STYLE[type] ?? TYPE_STYLE.user;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="inline" as="span">
|
||||||
|
<span
|
||||||
|
className={`${style.bg} ${style.text} rounded px-1.5 py-0.5 text-sm font-medium select-none cursor-default inline-flex items-center leading-tight`}
|
||||||
|
data-mention
|
||||||
|
data-id={attrs.id}
|
||||||
|
data-label={label}
|
||||||
|
data-type={type}
|
||||||
|
>
|
||||||
|
{style.prefix}{label}
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,13 +2,14 @@
|
|||||||
* Core types for the IM editor (mentions, files, emojis).
|
* Core types for the IM editor (mentions, files, emojis).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type MentionType = 'user' | 'channel' | 'ai' | 'command';
|
export type MentionType = 'user' | 'channel' | 'ai' | 'command' | 'special_here' | 'special_channel';
|
||||||
|
|
||||||
export interface MentionItem {
|
export interface MentionItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: MentionType;
|
type: MentionType;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
description?: string; // shown under label in suggestion dropdown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileData {
|
export interface FileData {
|
||||||
|
|||||||
@ -18,6 +18,8 @@ import {
|
|||||||
type RoomPinResponse,
|
type RoomPinResponse,
|
||||||
type RoomResponse,
|
type RoomResponse,
|
||||||
type RoomThreadResponse,
|
type RoomThreadResponse,
|
||||||
|
categoryList as restCategoryList,
|
||||||
|
roomList as restRoomList,
|
||||||
} from '@/client';
|
} from '@/client';
|
||||||
import {
|
import {
|
||||||
createRoomWsClient,
|
createRoomWsClient,
|
||||||
@ -159,6 +161,9 @@ interface RoomContextValue {
|
|||||||
/** Room AI configs for @ai: mention suggestions */
|
/** Room AI configs for @ai: mention suggestions */
|
||||||
roomAiConfigs: RoomAiConfig[];
|
roomAiConfigs: RoomAiConfig[];
|
||||||
aiConfigsLoading?: boolean;
|
aiConfigsLoading?: boolean;
|
||||||
|
|
||||||
|
/** Typing users in the active room: roomId -> userId -> { username, avatar_url } */
|
||||||
|
typingUsers: Record<string, Record<string, { username: string; avatar_url?: string; timeoutId?: ReturnType<typeof setTimeout> }>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RoomContext = createContext<RoomContextValue | null>(null);
|
const RoomContext = createContext<RoomContextValue | null>(null);
|
||||||
@ -224,6 +229,15 @@ export function RoomProvider({
|
|||||||
const [categories, setCategories] = useState<RoomCategoryResponse[]>([]);
|
const [categories, setCategories] = useState<RoomCategoryResponse[]>([]);
|
||||||
const [categoriesLoading, setCategoriesLoading] = useState(false);
|
const [categoriesLoading, setCategoriesLoading] = useState(false);
|
||||||
|
|
||||||
|
// Merge category_info into rooms whenever either changes
|
||||||
|
const roomsWithCategory = useMemo<RoomWithCategory[]>(() => {
|
||||||
|
const catMap = new Map(categories.map((c) => [c.id, c]));
|
||||||
|
return rooms.map((r) => ({
|
||||||
|
...r,
|
||||||
|
category_info: r.category ? (catMap.get(r.category) ?? null) : null,
|
||||||
|
}));
|
||||||
|
}, [rooms, categories]);
|
||||||
|
|
||||||
const [activeRoom, setActiveRoomState] = useState<RoomResponse | null>(null);
|
const [activeRoom, setActiveRoomState] = useState<RoomResponse | null>(null);
|
||||||
|
|
||||||
const [messages, setMessages] = useState<MessageWithMeta[]>([]);
|
const [messages, setMessages] = useState<MessageWithMeta[]>([]);
|
||||||
@ -413,6 +427,9 @@ export function RoomProvider({
|
|||||||
// User presence map: user_id -> status
|
// User presence map: user_id -> status
|
||||||
const [presence, setPresence] = useState<PresenceMap>({});
|
const [presence, setPresence] = useState<PresenceMap>({});
|
||||||
|
|
||||||
|
// Typing users map: roomId -> Map<userId, { username, avatar_url, timeoutId }>
|
||||||
|
const [typingUsers, setTypingUsers] = useState<Record<string, Record<string, { username: string; avatar_url?: string; timeoutId?: ReturnType<typeof setTimeout> }>>>({});
|
||||||
|
|
||||||
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
|
const [streamingContent, setStreamingContent] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
// Project repos for @repository: mention suggestions
|
// Project repos for @repository: mention suggestions
|
||||||
@ -638,6 +655,41 @@ export function RoomProvider({
|
|||||||
if (payload.room_id !== activeRoomIdRef.current) return;
|
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||||
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
|
setPresence((prev) => ({ ...prev, [payload.user_id]: payload.status }));
|
||||||
},
|
},
|
||||||
|
onTypingStart: (payload) => {
|
||||||
|
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||||
|
if (payload.user_id === user?.uid) return; // Don't show self
|
||||||
|
setTypingUsers((prev) => {
|
||||||
|
const roomMap = prev[payload.room_id] ?? {};
|
||||||
|
// Clear existing timeout for this user
|
||||||
|
const existing = roomMap[payload.user_id];
|
||||||
|
if (existing?.timeoutId) clearTimeout(existing.timeoutId);
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
setTypingUsers((p) => {
|
||||||
|
const rm = { ...p[payload.room_id] };
|
||||||
|
delete rm[payload.user_id];
|
||||||
|
return { ...p, [payload.room_id]: rm };
|
||||||
|
});
|
||||||
|
}, 4000);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[payload.room_id]: {
|
||||||
|
...roomMap,
|
||||||
|
[payload.user_id]: { username: payload.username, avatar_url: payload.avatar_url, timeoutId },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onTypingStop: (payload) => {
|
||||||
|
if (payload.room_id !== activeRoomIdRef.current) return;
|
||||||
|
setTypingUsers((prev) => {
|
||||||
|
const roomMap = prev[payload.room_id] ?? {};
|
||||||
|
const existing = roomMap[payload.user_id];
|
||||||
|
if (existing?.timeoutId) clearTimeout(existing.timeoutId);
|
||||||
|
const newRoomMap = { ...roomMap };
|
||||||
|
delete newRoomMap[payload.user_id];
|
||||||
|
return { ...prev, [payload.room_id]: newRoomMap };
|
||||||
|
});
|
||||||
|
},
|
||||||
onStatusChange: (status) => {
|
onStatusChange: (status) => {
|
||||||
setWsStatus(status);
|
setWsStatus(status);
|
||||||
if (status === 'closed' || status === 'error') {
|
if (status === 'closed' || status === 'error') {
|
||||||
@ -682,16 +734,20 @@ export function RoomProvider({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchRooms = useCallback(async () => {
|
const fetchRooms = useCallback(async () => {
|
||||||
const client = wsClientRef.current;
|
if (!projectName) {
|
||||||
if (!projectName || !client) {
|
|
||||||
setRooms([]);
|
setRooms([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRoomsLoading(true);
|
setRoomsLoading(true);
|
||||||
setRoomsError(null);
|
setRoomsError(null);
|
||||||
try {
|
try {
|
||||||
const resp = await client.roomList(projectName);
|
const resp = await restRoomList({ path: { project_name: projectName } });
|
||||||
setRooms(resp.map((r) => ({ ...r, category_info: null })));
|
const data = resp.data?.data;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setRooms(data.map((r) => ({ ...r, category_info: null })));
|
||||||
|
} else {
|
||||||
|
setRooms([]);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setRoomsError(err instanceof Error ? err : new Error('Failed to load rooms'));
|
setRoomsError(err instanceof Error ? err : new Error('Failed to load rooms'));
|
||||||
} finally {
|
} finally {
|
||||||
@ -704,12 +760,12 @@ export function RoomProvider({
|
|||||||
}, [fetchRooms]);
|
}, [fetchRooms]);
|
||||||
|
|
||||||
const fetchCategories = useCallback(async () => {
|
const fetchCategories = useCallback(async () => {
|
||||||
const client = wsClientRef.current;
|
if (!projectName) return;
|
||||||
if (!projectName || !client) return;
|
|
||||||
setCategoriesLoading(true);
|
setCategoriesLoading(true);
|
||||||
try {
|
try {
|
||||||
const resp = await client.categoryList(projectName);
|
const resp = await restCategoryList({ path: { project_name: projectName } });
|
||||||
setCategories(resp);
|
const data = resp.data?.data;
|
||||||
|
setCategories(Array.isArray(data) ? data : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleRoomError('Load categories', error);
|
handleRoomError('Load categories', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -1236,7 +1292,7 @@ export function RoomProvider({
|
|||||||
wsClient: wsClientRef.current,
|
wsClient: wsClientRef.current,
|
||||||
connectWs,
|
connectWs,
|
||||||
disconnectWs,
|
disconnectWs,
|
||||||
rooms,
|
rooms: roomsWithCategory,
|
||||||
roomsLoading,
|
roomsLoading,
|
||||||
roomsError,
|
roomsError,
|
||||||
refreshRooms: fetchRooms,
|
refreshRooms: fetchRooms,
|
||||||
@ -1283,6 +1339,7 @@ export function RoomProvider({
|
|||||||
reposLoading,
|
reposLoading,
|
||||||
roomAiConfigs,
|
roomAiConfigs,
|
||||||
aiConfigsLoading,
|
aiConfigsLoading,
|
||||||
|
typingUsers,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
wsStatus,
|
wsStatus,
|
||||||
@ -1290,7 +1347,7 @@ export function RoomProvider({
|
|||||||
connectWs,
|
connectWs,
|
||||||
disconnectWs,
|
disconnectWs,
|
||||||
wsClientRef.current,
|
wsClientRef.current,
|
||||||
rooms,
|
roomsWithCategory,
|
||||||
roomsLoading,
|
roomsLoading,
|
||||||
roomsError,
|
roomsError,
|
||||||
fetchRooms,
|
fetchRooms,
|
||||||
@ -1336,6 +1393,7 @@ export function RoomProvider({
|
|||||||
reposLoading,
|
reposLoading,
|
||||||
roomAiConfigs,
|
roomAiConfigs,
|
||||||
aiConfigsLoading,
|
aiConfigsLoading,
|
||||||
|
typingUsers,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -78,6 +78,8 @@ export interface RoomWsCallbacks {
|
|||||||
onMessagePinned?: (payload: import('./ws-protocol').MessagePinnedPayload) => void;
|
onMessagePinned?: (payload: import('./ws-protocol').MessagePinnedPayload) => void;
|
||||||
onMessageUnpinned?: (payload: import('./ws-protocol').MessageUnpinnedPayload) => void;
|
onMessageUnpinned?: (payload: import('./ws-protocol').MessageUnpinnedPayload) => void;
|
||||||
onUserPresence?: (payload: UserPresencePayload) => void;
|
onUserPresence?: (payload: UserPresencePayload) => void;
|
||||||
|
onTypingStart?: (payload: import('./ws-protocol').TypingStartPayload) => void;
|
||||||
|
onTypingStop?: (payload: import('./ws-protocol').TypingStopPayload) => void;
|
||||||
onStatusChange?: (status: RoomWsStatus) => void;
|
onStatusChange?: (status: RoomWsStatus) => void;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
/** Called each time the client sends a heartbeat ping */
|
/** Called each time the client sends a heartbeat ping */
|
||||||
@ -961,6 +963,14 @@ export class RoomWsClient {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Send a typing_start / typing_stop event directly via WebSocket push (no response needed). */
|
||||||
|
sendTyping(roomId: string, action: 'start' | 'stop'): void {
|
||||||
|
if (this.ws && this.status === 'open') {
|
||||||
|
const event = { type: 'event', event: `typing_${action}`, room_id: roomId };
|
||||||
|
this.ws.send(JSON.stringify(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleMessage(rawText: string): void {
|
private handleMessage(rawText: string): void {
|
||||||
// Handle raw JSON pong before full parsing — resets heartbeat
|
// Handle raw JSON pong before full parsing — resets heartbeat
|
||||||
if (rawText.trim() === '{"type":"pong"}') {
|
if (rawText.trim() === '{"type":"pong"}') {
|
||||||
@ -1033,6 +1043,22 @@ export class RoomWsClient {
|
|||||||
status: ((event.data as { status?: string })?.status ?? 'offline') as 'online' | 'away' | 'dnd' | 'offline',
|
status: ((event.data as { status?: string })?.status ?? 'offline') as 'online' | 'away' | 'dnd' | 'offline',
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'typing.start':
|
||||||
|
case 'typing_start':
|
||||||
|
this.callbacks.onTypingStart?.({
|
||||||
|
room_id: event.room_id ?? '',
|
||||||
|
user_id: (event.data as { user_id?: string })?.user_id ?? '',
|
||||||
|
username: (event.data as { username?: string })?.username ?? '',
|
||||||
|
avatar_url: (event.data as { avatar_url?: string })?.avatar_url,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'typing.stop':
|
||||||
|
case 'typing_stop':
|
||||||
|
this.callbacks.onTypingStop?.({
|
||||||
|
room_id: event.room_id ?? '',
|
||||||
|
user_id: (event.data as { user_id?: string })?.user_id ?? '',
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
// Unknown event type - ignore silently
|
// Unknown event type - ignore silently
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -133,7 +133,20 @@ export type WsResponseData =
|
|||||||
| NotificationListData
|
| NotificationListData
|
||||||
| MentionListData
|
| MentionListData
|
||||||
| SubscribeData
|
| SubscribeData
|
||||||
| UserInfo[];
|
| UserInfo[]
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export interface TypingStartPayload {
|
||||||
|
room_id: string;
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TypingStopPayload {
|
||||||
|
room_id: string;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WsEvent {
|
export interface WsEvent {
|
||||||
type: 'event';
|
type: 'event';
|
||||||
@ -155,6 +168,8 @@ export type WsEventPayload =
|
|||||||
| { type: 'message_pinned'; data: MessagePinnedPayload }
|
| { type: 'message_pinned'; data: MessagePinnedPayload }
|
||||||
| { type: 'message_unpinned'; data: MessageUnpinnedPayload }
|
| { type: 'message_unpinned'; data: MessageUnpinnedPayload }
|
||||||
| { type: 'user_presence'; data: UserPresencePayload }
|
| { type: 'user_presence'; data: UserPresencePayload }
|
||||||
|
| { type: 'typing_start'; data: TypingStartPayload }
|
||||||
|
| { type: 'typing_stop'; data: TypingStopPayload }
|
||||||
| { type: string; data: unknown }; // catch-all for unknown events
|
| { type: string; data: unknown }; // catch-all for unknown events
|
||||||
|
|
||||||
export interface RoomMessagePayload {
|
export interface RoomMessagePayload {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user