Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd
This commit is contained in:
commit
4511c0dee7
282 files changed
+27745
-5360
No files matched your search
@@ -245,9 +245,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
// first paint is already roughly laid out instead of an exploding
|
||||
// cloud that drifts out of the viewport.
|
||||
if (props.layoutType === "force") {
|
||||
const deadline = performance.now() + 120;
|
||||
const budget = simNodes.length > 350 ? 280 : 120;
|
||||
const deadline = performance.now() + budget;
|
||||
let guard = 0;
|
||||
while (sim.alpha > 0.25 && guard < 300 && performance.now() < deadline) {
|
||||
while (sim.alpha > 0.25 && guard < 600 && performance.now() < deadline) {
|
||||
sim.tick();
|
||||
guard += 1;
|
||||
}
|
||||
@@ -295,9 +296,15 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
|
||||
|
||||
// Notify React of zoom changes only when k actually moved — a state
|
||||
// update per animation frame re-renders the whole page while settling.
|
||||
const lastNotifiedZoomRef = useRef(0);
|
||||
const setTransform = useCallback((t: Transform) => {
|
||||
transformRef.current = t;
|
||||
propsRef.current.onZoomChange(t.k);
|
||||
if (Math.abs(t.k - lastNotifiedZoomRef.current) > 0.004) {
|
||||
lastNotifiedZoomRef.current = t.k;
|
||||
propsRef.current.onZoomChange(t.k);
|
||||
}
|
||||
needsDrawRef.current = true;
|
||||
}, []);
|
||||
|
||||
@@ -326,32 +333,39 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
[setTransform],
|
||||
);
|
||||
|
||||
const computeFitTransform = useCallback((): Transform | null => {
|
||||
const sim = simRef.current;
|
||||
const { w, h } = sizeRef.current;
|
||||
if (!sim || !sim.nodes.length || !w || !h) return null;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const n of sim.nodes) {
|
||||
minX = Math.min(minX, n.x - n.radius);
|
||||
minY = Math.min(minY, n.y - n.radius);
|
||||
maxX = Math.max(maxX, n.x + n.radius);
|
||||
maxY = Math.max(maxY, n.y + n.radius);
|
||||
}
|
||||
// A single bad coordinate must not poison the viewport transform.
|
||||
if (!Number.isFinite(minX + minY + maxX + maxY)) return null;
|
||||
const pad = 70;
|
||||
const bw = Math.max(maxX - minX, 1);
|
||||
const bh = Math.max(maxY - minY, 1);
|
||||
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
|
||||
const cx = (minX + maxX) / 2;
|
||||
const cy = (minY + maxY) / 2;
|
||||
return { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
|
||||
}, []);
|
||||
|
||||
const fitView = useCallback(
|
||||
(animated = true) => {
|
||||
const sim = simRef.current;
|
||||
const { w, h } = sizeRef.current;
|
||||
if (!sim || !sim.nodes.length || !w || !h) return;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const n of sim.nodes) {
|
||||
minX = Math.min(minX, n.x - n.radius);
|
||||
minY = Math.min(minY, n.y - n.radius);
|
||||
maxX = Math.max(maxX, n.x + n.radius);
|
||||
maxY = Math.max(maxY, n.y + n.radius);
|
||||
}
|
||||
const pad = 70;
|
||||
const bw = Math.max(maxX - minX, 1);
|
||||
const bh = Math.max(maxY - minY, 1);
|
||||
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
|
||||
const cx = (minX + maxX) / 2;
|
||||
const cy = (minY + maxY) / 2;
|
||||
const target = { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
|
||||
const target = computeFitTransform();
|
||||
if (!target) return;
|
||||
if (animated) animateTo(target);
|
||||
else setTransform(target);
|
||||
},
|
||||
[animateTo, setTransform],
|
||||
[animateTo, computeFitTransform, setTransform],
|
||||
);
|
||||
|
||||
const focusNode = useCallback(
|
||||
@@ -527,11 +541,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
// Glow for hubs and the active node.
|
||||
if (
|
||||
alpha > 0.5 &&
|
||||
(isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold)
|
||||
) {
|
||||
// Glow for hubs and the active node. Canvas shadows are expensive,
|
||||
// so on large graphs only the focused nodes keep theirs.
|
||||
const glowEligible =
|
||||
sim.nodes.length <= 450
|
||||
? isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold
|
||||
: isActive || isSelected || isExploreRoot;
|
||||
if (alpha > 0.5 && glowEligible) {
|
||||
ctx.shadowColor = fillColor;
|
||||
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
|
||||
} else {
|
||||
@@ -692,6 +708,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
};
|
||||
if (progress >= 1) {
|
||||
animRef.current = null;
|
||||
lastNotifiedZoomRef.current = transformRef.current.k;
|
||||
propsRef.current.onZoomChange(transformRef.current.k);
|
||||
}
|
||||
active = true;
|
||||
@@ -699,10 +716,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
if (sim && layoutRef.current === "force" && sim.tick()) {
|
||||
active = true;
|
||||
// Until the user pans/zooms/drags, keep the settling layout fully in
|
||||
// view every frame so nodes never drift out of the viewport.
|
||||
// Until the user pans/zooms/drags, gently track the settling layout
|
||||
// so it stays in view without the viewport snapping every frame.
|
||||
if (!interactedRef.current && sizeRef.current.w) {
|
||||
fitView(false);
|
||||
const target = computeFitTransform();
|
||||
if (target) {
|
||||
const t = transformRef.current;
|
||||
const ease = t.k === 0 ? 1 : 0.16;
|
||||
setTransform({
|
||||
x: t.x + (target.x - t.x) * ease,
|
||||
y: t.y + (target.y - t.y) * ease,
|
||||
k: t.k + (target.k - t.k) * ease,
|
||||
});
|
||||
}
|
||||
autoFitRef.current = true;
|
||||
} else if (!autoFitRef.current && sim.alpha < 0.3) {
|
||||
autoFitRef.current = true;
|
||||
@@ -721,7 +747,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [draw, fitView]);
|
||||
}, [computeFitTransform, draw, fitView, setTransform]);
|
||||
|
||||
// --------------------------------------------------------------- resize
|
||||
|
||||
@@ -801,7 +827,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
interactedRef.current = true;
|
||||
const { sx, sy } = localPoint(e);
|
||||
const { x: wx, y: wy } = screenToWorld(sx, sy);
|
||||
const node = hitTest(wx, wy);
|
||||
@@ -828,7 +853,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
if (drag && drag.pointerId === e.pointerId) {
|
||||
const dx = sx - drag.startX;
|
||||
const dy = sy - drag.startY;
|
||||
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
|
||||
if (Math.abs(dx) + Math.abs(dy) > 3) {
|
||||
drag.moved = true;
|
||||
// Only a real pan/drag takes over the camera; a plain click keeps
|
||||
// the auto-follow so the settling graph can't drift out of view.
|
||||
interactedRef.current = true;
|
||||
}
|
||||
if (drag.node) {
|
||||
const world = screenToWorld(sx, sy);
|
||||
drag.node.fx = world.x;
|
||||
|
||||
@@ -239,15 +239,41 @@ export function applyCircularLayout(nodes: SimNode[]): void {
|
||||
export class ForceSimulation {
|
||||
alpha = 1;
|
||||
readonly alphaMin = 0.004;
|
||||
private readonly alphaDecay = 0.0228;
|
||||
private readonly alphaDecay: number;
|
||||
private readonly velocityDecay = 0.38;
|
||||
private readonly chargeStrength = -420;
|
||||
private readonly chargeCutoff2 = 600 * 600;
|
||||
private readonly chargeStrength: number;
|
||||
private readonly chargeCutoff: number;
|
||||
private readonly chargeCutoff2: number;
|
||||
/** Hard cap on per-tick movement so dense graphs can't oscillate or fly off. */
|
||||
private readonly maxSpeed = 30;
|
||||
|
||||
constructor(
|
||||
public nodes: SimNode[],
|
||||
public edges: SimEdge[],
|
||||
) {}
|
||||
) {
|
||||
// Scale forces down and cool faster as the graph grows — with the
|
||||
// small-graph constants a few hundred nodes keep shoving each other
|
||||
// around for minutes and the layout never settles.
|
||||
const n = nodes.length;
|
||||
if (n > 600) {
|
||||
this.chargeStrength = -160;
|
||||
this.chargeCutoff = 260;
|
||||
this.alphaDecay = 0.05;
|
||||
} else if (n > 300) {
|
||||
this.chargeStrength = -240;
|
||||
this.chargeCutoff = 340;
|
||||
this.alphaDecay = 0.038;
|
||||
} else if (n > 150) {
|
||||
this.chargeStrength = -320;
|
||||
this.chargeCutoff = 440;
|
||||
this.alphaDecay = 0.03;
|
||||
} else {
|
||||
this.chargeStrength = -420;
|
||||
this.chargeCutoff = 600;
|
||||
this.alphaDecay = 0.0228;
|
||||
}
|
||||
this.chargeCutoff2 = this.chargeCutoff * this.chargeCutoff;
|
||||
}
|
||||
|
||||
reheat(alpha = 0.9): void {
|
||||
this.alpha = Math.max(this.alpha, alpha);
|
||||
@@ -276,31 +302,8 @@ export class ForceSimulation {
|
||||
s.vy += dy * 0.5;
|
||||
}
|
||||
|
||||
// Many-body repulsion with a distance cutoff.
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const ni = nodes[i];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const nj = nodes[j];
|
||||
let dx = nj.x - ni.x;
|
||||
let dy = nj.y - ni.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 > this.chargeCutoff2) continue;
|
||||
if (d2 < 1) {
|
||||
// Deterministic nudge for coincident nodes.
|
||||
dx = ((i % 3) - 1) * 0.5 || 0.5;
|
||||
dy = ((j % 3) - 1) * 0.5 || -0.5;
|
||||
d2 = dx * dx + dy * dy;
|
||||
}
|
||||
const d = Math.sqrt(d2);
|
||||
const f = (this.chargeStrength * a) / d2;
|
||||
const fx = (dx / d) * f;
|
||||
const fy = (dy / d) * f;
|
||||
ni.vx += fx;
|
||||
ni.vy += fy;
|
||||
nj.vx -= fx;
|
||||
nj.vy -= fy;
|
||||
}
|
||||
}
|
||||
// Repulsion + collision over nearby pairs only (uniform grid).
|
||||
this.applyPairForces(a);
|
||||
|
||||
// Gentle pull toward the origin keeps disconnected clusters on screen.
|
||||
for (const n of nodes) {
|
||||
@@ -308,33 +311,9 @@ export class ForceSimulation {
|
||||
n.vy += (0 - n.y) * 0.05 * a;
|
||||
}
|
||||
|
||||
// Positional collision resolution (prevents node overlap).
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const ni = nodes[i];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const nj = nodes[j];
|
||||
const minDist = ni.radius + nj.radius + 8;
|
||||
let dx = nj.x - ni.x;
|
||||
let dy = nj.y - ni.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 >= minDist * minDist || d2 === 0) continue;
|
||||
const d = Math.sqrt(d2);
|
||||
const push = ((minDist - d) / d) * 0.5;
|
||||
dx *= push;
|
||||
dy *= push;
|
||||
if (ni.fx == null) {
|
||||
ni.x -= dx;
|
||||
ni.y -= dy;
|
||||
}
|
||||
if (nj.fx == null) {
|
||||
nj.x += dx;
|
||||
nj.y += dy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Integrate velocities.
|
||||
for (const n of nodes) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const n = nodes[i];
|
||||
if (n.fx != null && n.fy != null) {
|
||||
n.x = n.fx;
|
||||
n.y = n.fy;
|
||||
@@ -343,10 +322,113 @@ export class ForceSimulation {
|
||||
} else {
|
||||
n.vx *= 1 - this.velocityDecay;
|
||||
n.vy *= 1 - this.velocityDecay;
|
||||
const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
|
||||
if (speed > this.maxSpeed) {
|
||||
const s = this.maxSpeed / speed;
|
||||
n.vx *= s;
|
||||
n.vy *= s;
|
||||
}
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
}
|
||||
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) {
|
||||
// A NaN/Infinity coordinate would blank the whole canvas (the
|
||||
// bounding box and fit transform become NaN); park the node instead.
|
||||
const r = 40 + 18 * Math.sqrt(i + 1);
|
||||
n.x = r * Math.cos(i * GOLDEN_ANGLE);
|
||||
n.y = r * Math.sin(i * GOLDEN_ANGLE);
|
||||
n.vx = 0;
|
||||
n.vy = 0;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Many-body repulsion + collision resolution, restricted to pairs within
|
||||
* one grid cell (cell size = charge cutoff). Two nodes farther apart than
|
||||
* the cutoff never interact, so binning by cell keeps the scan close to
|
||||
* O(n) for spread-out layouts instead of O(n²) over all pairs.
|
||||
*/
|
||||
private applyPairForces(a: number): void {
|
||||
const cellSize = this.chargeCutoff;
|
||||
const grid = new Map<string, { cx: number; cy: number; items: SimNode[] }>();
|
||||
for (const n of this.nodes) {
|
||||
const cx = Math.floor(n.x / cellSize);
|
||||
const cy = Math.floor(n.y / cellSize);
|
||||
const key = `${cx},${cy}`;
|
||||
let bucket = grid.get(key);
|
||||
if (!bucket) grid.set(key, (bucket = { cx, cy, items: [] }));
|
||||
bucket.items.push(n);
|
||||
}
|
||||
// Half of the 8-neighborhood so every cell pair is visited exactly once.
|
||||
const offsets = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
[1, -1],
|
||||
] as const;
|
||||
for (const bucket of grid.values()) {
|
||||
for (const [ox, oy] of offsets) {
|
||||
const other =
|
||||
ox === 0 && oy === 0
|
||||
? bucket
|
||||
: grid.get(`${bucket.cx + ox},${bucket.cy + oy}`);
|
||||
if (!other) continue;
|
||||
const left = bucket.items;
|
||||
const right = other.items;
|
||||
if (other === bucket) {
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
for (let j = i + 1; j < left.length; j++) {
|
||||
this.pairForce(left[i], left[j], i, j, a);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
for (let j = 0; j < right.length; j++) {
|
||||
this.pairForce(left[i], right[j], i, j, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private pairForce(ni: SimNode, nj: SimNode, i: number, j: number, a: number): void {
|
||||
let dx = nj.x - ni.x;
|
||||
let dy = nj.y - ni.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 > this.chargeCutoff2) return;
|
||||
if (d2 < 1) {
|
||||
// Deterministic nudge for coincident nodes.
|
||||
dx = ((i % 3) - 1) * 0.5 || 0.5;
|
||||
dy = ((j % 3) - 1) * 0.5 || -0.5;
|
||||
d2 = dx * dx + dy * dy;
|
||||
}
|
||||
const d = Math.sqrt(d2);
|
||||
const f = (this.chargeStrength * a) / d2;
|
||||
const fx = (dx / d) * f;
|
||||
const fy = (dy / d) * f;
|
||||
ni.vx += fx;
|
||||
ni.vy += fy;
|
||||
nj.vx -= fx;
|
||||
nj.vy -= fy;
|
||||
|
||||
// Positional collision resolution (prevents node overlap).
|
||||
const minDist = ni.radius + nj.radius + 8;
|
||||
if (d < minDist) {
|
||||
const push = ((minDist - d) / d) * 0.5;
|
||||
const px = dx * push;
|
||||
const py = dy * push;
|
||||
if (ni.fx == null) {
|
||||
ni.x -= px;
|
||||
ni.y -= py;
|
||||
}
|
||||
if (nj.fx == null) {
|
||||
nj.x += px;
|
||||
nj.y += py;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,8 @@
|
||||
width: 100%;
|
||||
height: 75px;
|
||||
margin-top: 13px;
|
||||
font-family: Noto Sans S Chinese;
|
||||
font-weight: 500;
|
||||
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
@@ -111,6 +111,42 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.sidebar-contnet--top-submenu .sidebar-contents {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-contnet--top-submenu .item-nav {
|
||||
height: auto;
|
||||
min-height: 75px;
|
||||
padding: 10px 2px;
|
||||
font-size: 19px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.sidebar-contnet--top-submenu .item-nav__label {
|
||||
width: 3.1em;
|
||||
max-width: 100%;
|
||||
padding-left: 0;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
word-break: normal;
|
||||
overflow-wrap: normal;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.sidebar-contnet--top-submenu .item-nav__label--four-char {
|
||||
width: 2.2em;
|
||||
}
|
||||
|
||||
.sidebar-contnet--top-submenu .item-nav:hover::after,
|
||||
.sidebar-contnet--top-submenu .item-nav-active::after,
|
||||
.sidebar-contnet--top-submenu .item-nav-hovered::after {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.item-nav-content {
|
||||
width: 155px;
|
||||
height: calc(100vh - 4rem - 20px);
|
||||
@@ -194,10 +230,10 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 3px 0;
|
||||
font-family: Noto Sans S Chinese;
|
||||
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: left;
|
||||
@@ -320,4 +356,390 @@
|
||||
max-width: 64px;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-contnet--top-submenu .item-nav:hover::before,
|
||||
.sidebar-contnet--top-submenu .item-nav-active::before,
|
||||
.sidebar-contnet--top-submenu .item-nav-hovered::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.top-submenu-bar {
|
||||
position: relative;
|
||||
z-index: 35;
|
||||
height: 64px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
align-self: stretch;
|
||||
margin-left: 34px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* 左右分组:不撑满、不裁切选中高光 */
|
||||
.top-submenu-bar--group {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.top-submenu-list {
|
||||
min-width: max-content;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.top-submenu-item {
|
||||
position: relative;
|
||||
min-width: 122px;
|
||||
height: 64px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
padding: 0 18px;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
overflow: visible;
|
||||
transition: color 0.18s ease, text-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.top-submenu-item:hover {
|
||||
color: #9fe3ff;
|
||||
text-shadow: 0 0 10px rgba(91, 210, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 选中态:设计稿提供的 nav-active.svg 发光高光 + 青色文字 */
|
||||
.top-submenu-item--active {
|
||||
color: #64cdf5;
|
||||
text-shadow: 0 0 12px rgba(100, 205, 245, 0.5);
|
||||
background-image: url("../assets/img/nav-active.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-position: center calc(50% + 7px);
|
||||
background-size: 152px 42px;
|
||||
}
|
||||
|
||||
.third-submenu-bar {
|
||||
position: relative;
|
||||
z-index: 34;
|
||||
height: 42px;
|
||||
margin-left: 118px;
|
||||
flex-shrink: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(4, 34, 78, 0.96) 0%, rgba(2, 24, 58, 0.96) 100%),
|
||||
radial-gradient(ellipse at top left, rgba(45, 149, 255, 0.18) 0%, rgba(45, 149, 255, 0) 42%);
|
||||
border-top: 1px solid rgba(79, 154, 255, 0.18);
|
||||
border-bottom: 1px solid rgba(43, 143, 254, 0.26);
|
||||
box-shadow: inset 0 1px 0 rgba(115, 194, 255, 0.08), inset 0 -10px 18px rgba(0, 26, 70, 0.22);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.third-submenu-list {
|
||||
min-width: max-content;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.third-submenu-item {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
min-width: 86px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(90, 169, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
padding: 0 16px;
|
||||
background: rgba(2, 28, 65, 0.36);
|
||||
color: rgba(221, 242, 255, 0.78);
|
||||
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", "Microsoft YaHei", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.third-submenu-item:hover,
|
||||
.third-submenu-item--active {
|
||||
color: #ffffff;
|
||||
border-color: rgba(82, 205, 255, 0.55);
|
||||
background: linear-gradient(180deg, rgba(14, 84, 165, 0.54) 0%, rgba(4, 45, 104, 0.58) 100%);
|
||||
box-shadow: inset 0 0 12px rgba(0, 185, 255, 0.14), 0 0 10px rgba(35, 167, 255, 0.14);
|
||||
text-shadow: 0 0 8px rgba(87, 215, 255, 0.34);
|
||||
}
|
||||
|
||||
.third-submenu-item--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: -1px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, rgba(62, 178, 255, 0) 0%, #4ee6ff 50%, rgba(62, 178, 255, 0) 100%);
|
||||
box-shadow: 0 0 10px rgba(78, 230, 255, 0.72);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════
|
||||
浅色主题导航(红/蓝两套,颜色经 --brand-* 变量自动切换)
|
||||
逻辑同深色:一级导航在顶栏、二级导航在侧边栏、三级导航在顶栏下方横条。
|
||||
但一级导航不做深色那种围绕基座的特殊处理,正常横排即可。
|
||||
══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── 顶栏一级导航(白字落在 red/blue-header banner 上)──────────────────── */
|
||||
.light-topnav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
/* 整体往右移(用百分比,随容器宽度自适应;约等于宽屏下的 200px 量级) */
|
||||
margin-left: 10%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.light-topnav-item {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
/* 复用全局字体栈(与「多智能体会商」等页面一致):Source Han Sans SC 在 Windows 上
|
||||
常按字重分家族,放栈首会导致 font-weight 选不到粗体;用 --font-sans(Noto Sans SC
|
||||
领先)才能正常加粗。 */
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
/* 普通项用 500(雅黑兜底下渲染为常规字面),选中项 700 才能看出粗体对比 */
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
.light-topnav-item:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 选中态:不改背景,文字加粗变亮 + 贴近导航栏底部一条粗白线 */
|
||||
.light-topnav-item--active,
|
||||
.light-topnav-item--active:hover {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.light-topnav-item--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
height: 4px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
|
||||
.light-sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 4rem;
|
||||
z-index: 40;
|
||||
width: 96px;
|
||||
height: calc(100vh - 4rem);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid var(--border);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
/* 展开三级列时整体变宽的过渡动画 */
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* 展开三级列:96(二级) + 140(三级) = 236px */
|
||||
.light-sidebar--expanded {
|
||||
width: 236px;
|
||||
}
|
||||
|
||||
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
||||
.light-sidebar__primary {
|
||||
flex: 0 0 96px;
|
||||
width: 96px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 横向 padding 置 0:让选中态浅红背景能铺满与列宽一致 */
|
||||
padding: 10px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 三级列:固定 140px,随侧边栏展开淡入 */
|
||||
.light-sidebar__secondary {
|
||||
flex: 0 0 140px;
|
||||
width: 140px;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--border);
|
||||
/* 横向 padding 置 0:让三级选中背景能铺满与列宽一致(文字内缩由 subitem 自身 padding 保证) */
|
||||
padding: 12px 0;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #ffffff;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.light-sidebar--expanded .light-sidebar__secondary {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.light-sidebar__list {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.light-sidebar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 14px 4px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #222222;
|
||||
/* 复用全局字体栈,确保加粗生效(详见 .light-topnav-item 注释) */
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.light-sidebar-item__icon {
|
||||
flex-shrink: 0;
|
||||
/* 图标统一用主题色(红主题红、蓝主题蓝) */
|
||||
color: var(--brand-900);
|
||||
transition: color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.light-sidebar-item__label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.light-sidebar-item:hover {
|
||||
background: var(--brand-200);
|
||||
}
|
||||
|
||||
.light-sidebar-item:hover .light-sidebar-item__icon {
|
||||
color: var(--brand-900);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.light-sidebar-item--active {
|
||||
background: var(--brand-200);
|
||||
color: var(--brand-900);
|
||||
font-weight: 600;
|
||||
/* 选中态:去圆角去边框,浅红底铺满父元素宽度,右侧一条粗主题色边(用 inset 阴影避免挤压内容) */
|
||||
box-shadow: inset -4px 0 0 0 var(--brand-900);
|
||||
}
|
||||
|
||||
.light-sidebar-item--active .light-sidebar-item__icon {
|
||||
color: var(--brand-900);
|
||||
}
|
||||
|
||||
/* 侧边栏底部:设置入口(浅色模式不支持收起,故无收起按钮) */
|
||||
.light-sidebar__footer {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── 三级列条目(左对齐图标 + 文字,参考 xiugai-1.png)─────────────────────── */
|
||||
.light-sidebar-subnav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.light-sidebar-subitem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #222222;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.light-sidebar-subitem__icon {
|
||||
flex-shrink: 0;
|
||||
/* 图标用主题色(红主题红、蓝主题蓝) */
|
||||
color: var(--brand-900);
|
||||
}
|
||||
|
||||
.light-sidebar-subitem__label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.light-sidebar-subitem:hover {
|
||||
background: var(--brand-200);
|
||||
}
|
||||
|
||||
.light-sidebar-subitem--active {
|
||||
background: var(--brand-200);
|
||||
color: var(--brand-900);
|
||||
font-weight: 700;
|
||||
box-shadow: inset -4px 0 0 0 var(--brand-900);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { PanelLeftOpen, PanelLeftClose } from "lucide-react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import {
|
||||
defaultExpandedIds,
|
||||
filterMenuItemsByRole,
|
||||
sidebarMenuItems,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||||
import './page-sdebar.css'
|
||||
|
||||
type PageSidebarDarkProps = {
|
||||
@@ -19,10 +19,8 @@ type PageSidebarDarkProps = {
|
||||
|
||||
export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useMemo(
|
||||
() => filterMenuItemsByRole(sidebarMenuItems, !!isAdmin),
|
||||
[isAdmin],
|
||||
);
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const inWujieEnv = isWujieEnv();
|
||||
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
|
||||
const navigate = useNavigate();
|
||||
@@ -56,7 +54,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
};
|
||||
|
||||
const hasActiveChild = (item: MenuItem): boolean => {
|
||||
if (item.path && pathname === item.path) {
|
||||
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -126,6 +124,13 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecondItemClick = (item: MenuItem) => {
|
||||
const path = item.path ?? findFirstRoutePath(item.children);
|
||||
if (path) {
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (item: MenuItem) => {
|
||||
if (item.children?.length) {
|
||||
toggleExpanded(item.id);
|
||||
@@ -137,6 +142,35 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
}
|
||||
};
|
||||
|
||||
const activeTopItem =
|
||||
visibleMenuItems.find((item) => item.id === activeTopId) ??
|
||||
visibleMenuItems.find(hasActiveChild) ??
|
||||
visibleMenuItems[0];
|
||||
const sidebarMenuItemsToRender = inWujieEnv
|
||||
? visibleMenuItems
|
||||
: activeTopItem?.children ?? [];
|
||||
const isSidebarItemActive = (item: MenuItem) =>
|
||||
inWujieEnv
|
||||
? item.id === activeTopId
|
||||
: item.path
|
||||
? pathname === item.path || pathname.startsWith(item.path + "/")
|
||||
: hasActiveChild(item);
|
||||
const handleSidebarItemClick = (item: MenuItem) => {
|
||||
if (inWujieEnv) {
|
||||
handleTopItemClick(item);
|
||||
return;
|
||||
}
|
||||
|
||||
handleSecondItemClick(item);
|
||||
};
|
||||
const getSidebarLabelClassName = (label: string) => {
|
||||
if (!inWujieEnv && Array.from(label.trim()).length === 4) {
|
||||
return "item-nav__label item-nav__label--four-char";
|
||||
}
|
||||
|
||||
return "item-nav__label";
|
||||
};
|
||||
|
||||
const renderFlyoutMenu = (items: MenuItem[], level = 0) => (
|
||||
<div className={`sidebar-flyout-list sidebar-flyout-list--level-${level}`}>
|
||||
{items.map((item) => {
|
||||
@@ -163,6 +197,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// collapsed 状态:sidebar 主体完全消失(包括占据的宽度),只渲染左下角浮动入口
|
||||
if (collapsed) {
|
||||
return (
|
||||
@@ -180,16 +215,16 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar-contnet">
|
||||
<aside className={`sidebar-contnet ${inWujieEnv ? "" : "sidebar-contnet--top-submenu"}`}>
|
||||
<div className="sidebar-contents">
|
||||
{visibleMenuItems.map((item) => (
|
||||
{sidebarMenuItemsToRender.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="item-nav-group"
|
||||
onMouseEnter={() => handleNavHoverEnter(item.id)}
|
||||
onMouseLeave={scheduleNavPanelClose}
|
||||
onMouseEnter={inWujieEnv ? () => handleNavHoverEnter(item.id) : undefined}
|
||||
onMouseLeave={inWujieEnv ? scheduleNavPanelClose : undefined}
|
||||
>
|
||||
{item.children?.length ? (
|
||||
{inWujieEnv && item.children?.length ? (
|
||||
<div
|
||||
className={`item-nav-content ${hoveredTopId === item.id ? "item-nav-content--visible" : ""}`}
|
||||
>
|
||||
@@ -199,12 +234,12 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
<div className="item-nav-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
|
||||
onClick={() => handleTopItemClick(item)}
|
||||
className={`item-nav ${isSidebarItemActive(item) ? "item-nav-active" : ""} ${hoveredTopId === item.id ? "item-nav-hovered" : ""}`}
|
||||
onClick={() => handleSidebarItemClick(item)}
|
||||
>
|
||||
<span className="item-nav__label">{item.label}</span>
|
||||
<span className={getSidebarLabelClassName(item.label)}>{item.label}</span>
|
||||
</button>
|
||||
{item.children?.length && hoveredTopId === item.id ? (
|
||||
{inWujieEnv && item.children?.length && hoveredTopId === item.id ? (
|
||||
<span className="item-nav-hover-bridge" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Bell,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { SettingsDialog } from "@/components/workspace/settings";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import {
|
||||
defaultExpandedIds,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||||
|
||||
type PageSidebarProps = {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
};
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
|
||||
export function PageSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: PageSidebarProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(defaultExpandedIds));
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] =
|
||||
useState<SettingsSection>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const openSettings = (section: SettingsSection) => {
|
||||
setSettingsDefaultSection(section);
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const iconButtonClass = isDark
|
||||
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
|
||||
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
|
||||
const panelClass = isDark
|
||||
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
|
||||
: "ml-4 space-y-1 border-l border-red-100 pl-4";
|
||||
const labelClass = (compact: boolean, small = false) =>
|
||||
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
|
||||
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
|
||||
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
|
||||
const menuButtonClass = (isActive: boolean, depth = 0) =>
|
||||
`group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3"
|
||||
} ${
|
||||
isActive
|
||||
? isDark
|
||||
? "bg-blue-500/15 text-cyan-300 font-semibold"
|
||||
: "bg-red-50 text-red-600 font-semibold"
|
||||
: isDark
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
|
||||
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
|
||||
}`;
|
||||
const footerButtonClass = menuButtonClass(false, 0);
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderLeaf = (item: MenuItem, depth = 0) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = !!item.path && pathname === item.path;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={collapsed && depth === 0 ? item.label : undefined}
|
||||
onClick={() => item.path && navigate(item.path)}
|
||||
className={menuButtonClass(isActive, depth)}
|
||||
>
|
||||
{isActive && (
|
||||
<span
|
||||
className={`absolute left-0 inset-y-0 w-[3px] rounded-r ${
|
||||
isDark ? "bg-cyan-300" : "bg-red-600"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<Icon className={`${depth === 0 ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105`} />
|
||||
{(!collapsed || depth > 0) && (
|
||||
<span className={labelClass(false, depth > 0)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBranch = (item: MenuItem, depth = 0) => {
|
||||
const expanded = expandedIds.has(item.id);
|
||||
const isTopLevel = depth === 0;
|
||||
const Icon = depth > 0 && item.icon === Folder && expanded ? FolderOpen : item.icon;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (collapsed && isTopLevel) {
|
||||
onToggleCollapsed();
|
||||
return;
|
||||
}
|
||||
toggleExpanded(item.id);
|
||||
}}
|
||||
title={collapsed && isTopLevel ? item.label : undefined}
|
||||
className={menuButtonClass(false, depth)}
|
||||
>
|
||||
<Icon
|
||||
className={`${isTopLevel ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105 ${
|
||||
expanded && depth > 0 ? "scale-105" : ""
|
||||
}`}
|
||||
/>
|
||||
{(!collapsed || !isTopLevel) && (
|
||||
<span className={labelClass(false, depth > 0)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{(!collapsed || !isTopLevel) && (
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform duration-300 ease-out ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{(!collapsed || !isTopLevel) && item.children && (
|
||||
<div
|
||||
className={`${panelClass} overflow-hidden transition-all duration-300 ease-out ${
|
||||
expanded ? "max-h-[1200px] opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{item.children.map((child) =>
|
||||
child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<aside
|
||||
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
isDark
|
||||
? "border-r border-blue-800/30 bg-slate-900"
|
||||
: "border-r border-red-100 bg-white"
|
||||
} ${collapsed ? "w-16" : "w-64"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
|
||||
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
|
||||
isDark
|
||||
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
||||
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
|
||||
}`}
|
||||
>
|
||||
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
|
||||
{visibleMenuItems.map((item) =>
|
||||
item.children ? renderBranch(item) : renderLeaf(item),
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={`mt-auto shrink-0 theme-transition ${
|
||||
isDark ? "border-t border-blue-800/30" : "border-t border-red-100"
|
||||
} ${collapsed ? "p-2" : "p-4"}`}
|
||||
>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={collapsed ? t.workspace.settingsAndMore : undefined}
|
||||
className={footerButtonClass}
|
||||
>
|
||||
<Settings className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className={labelClass(false)}>
|
||||
{t.workspace.settingsAndMore}
|
||||
</span>
|
||||
<ChevronsUpDown className="h-4 w-4 flex-shrink-0" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={collapsed ? "start" : "end"}
|
||||
side={collapsed ? "right" : "top"}
|
||||
sideOffset={4}
|
||||
className="min-w-56 rounded-lg"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||
<Bell className="size-4" />
|
||||
{t.notifications.title}
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={`ml-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium leading-none text-white ${
|
||||
isDark ? "bg-cyan-500" : "bg-red-500"
|
||||
}`}
|
||||
>
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openSettings("tools")}>
|
||||
<Wrench className="size-4" />
|
||||
{t.settings.sections.tools}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openSettings("account")}>
|
||||
<User className="size-4" />
|
||||
个人信息
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`${footerButtonClass} pointer-events-none`}
|
||||
>
|
||||
<Settings className="h-5 w-5 flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<span className={labelClass(false)}>
|
||||
{t.workspace.settingsAndMore}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full blur-3xl theme-transition ${
|
||||
isDark ? "bg-blue-500/10" : "bg-red-500/5"
|
||||
}`}
|
||||
/>
|
||||
<div
|
||||
className={`pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full blur-3xl theme-transition ${
|
||||
isDark ? "bg-cyan-400/5" : "bg-red-400/5"
|
||||
}`}
|
||||
/>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,14 +22,17 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { SettingsDialog } from "@/components/workspace/settings";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import {
|
||||
defaultExpandedIds,
|
||||
filterMenuItemsByRole,
|
||||
sidebarMenuItems,
|
||||
getActiveThirdLevelItems,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||||
|
||||
import "@/components/page-sdebar.css";
|
||||
|
||||
type PageSidebarProps = {
|
||||
collapsed: boolean;
|
||||
@@ -38,14 +41,210 @@ type PageSidebarProps = {
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
|
||||
export function PageSidebar({
|
||||
const hasActiveChild = (item: MenuItem, pathname: string): boolean => {
|
||||
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
|
||||
return true;
|
||||
}
|
||||
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
|
||||
};
|
||||
|
||||
const findFirstRoutePath = (items?: MenuItem[]): string | null => {
|
||||
if (!items?.length) {
|
||||
return null;
|
||||
}
|
||||
for (const child of items) {
|
||||
if (child.path) {
|
||||
return child.path;
|
||||
}
|
||||
const nested = findFirstRoutePath(child.children);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 浅色侧边栏入口。
|
||||
*
|
||||
* - 普通桌面环境:渲染「二级导航图标卡片」(一级在顶栏、二级在侧边栏,参考 导航-1.png)。
|
||||
* - wujie 嵌入环境:顶栏被宿主隐藏,一级导航无处安放,故回落到原手风琴整树菜单
|
||||
* (保证嵌入模式下所有层级仍可达,零回归)。
|
||||
*/
|
||||
export function PageSidebar({ collapsed, onToggleCollapsed }: PageSidebarProps) {
|
||||
if (isWujieEnv()) {
|
||||
return (
|
||||
<LegacyAccordionSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={onToggleCollapsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <SecondaryNavSidebar />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 浅色二级导航侧边栏(图标卡片,红/蓝主题色经 --brand-* 变量自动切换)。
|
||||
* 浅色模式不支持侧边栏收起,故无收起按钮、不接收 collapsed 相关 props。
|
||||
*/
|
||||
function SecondaryNavSidebar() {
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] =
|
||||
useState<SettingsSection>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const openSettings = (section: SettingsSection) => {
|
||||
setSettingsDefaultSection(section);
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
// 当前激活的一级菜单(与顶栏一级导航联动);其 children 即侧边栏要渲染的二级导航。
|
||||
const activeTopItem =
|
||||
visibleMenuItems.find((item) => hasActiveChild(item, pathname)) ??
|
||||
visibleMenuItems[0];
|
||||
const secondaryItems = activeTopItem?.children ?? [];
|
||||
|
||||
const isItemActive = (item: MenuItem) =>
|
||||
item.path
|
||||
? pathname === item.path || pathname.startsWith(item.path + "/")
|
||||
: hasActiveChild(item, pathname);
|
||||
|
||||
const handleItemClick = (item: MenuItem) => {
|
||||
const path = item.path ?? findFirstRoutePath(item.children);
|
||||
if (path) {
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
|
||||
// 当前激活二级项的三级子项;非空时侧边栏在右侧展开「三级列」并整体变宽(见 css 过渡)。
|
||||
const thirdItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
|
||||
const expanded = thirdItems.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<aside className={`light-sidebar ${expanded ? "light-sidebar--expanded" : ""}`}>
|
||||
{/* 一级列:二级图标卡片 + 底部设置 */}
|
||||
<div className="light-sidebar__primary">
|
||||
<nav className="light-sidebar__list" aria-label="二级菜单">
|
||||
{secondaryItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = isItemActive(item);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={item.label}
|
||||
onClick={() => handleItemClick(item)}
|
||||
className={`light-sidebar-item ${active ? "light-sidebar-item--active" : ""}`}
|
||||
>
|
||||
<Icon className="light-sidebar-item__icon h-7 w-7" />
|
||||
<span className="light-sidebar-item__label">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="light-sidebar__footer">
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title={t.workspace.settingsAndMore}
|
||||
className="light-sidebar-item"
|
||||
>
|
||||
<Settings className="light-sidebar-item__icon h-7 w-7" />
|
||||
<span className="light-sidebar-item__label">设置</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="min-w-56 rounded-lg"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||
<Bell className="size-4" />
|
||||
{t.notifications.title}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-[var(--brand-900)] px-1 text-[10px] font-medium leading-none text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openSettings("tools")}>
|
||||
<Wrench className="size-4" />
|
||||
{t.settings.sections.tools}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openSettings("account")}>
|
||||
<User className="size-4" />
|
||||
个人信息
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 二级列:三级导航(激活二级项有三级子项时展开,整体动画见 css width transition) */}
|
||||
<div className="light-sidebar__secondary" aria-hidden={!expanded}>
|
||||
<nav className="light-sidebar-subnav" aria-label="三级菜单">
|
||||
{thirdItems.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
const childActive = child.path
|
||||
? pathname === child.path || pathname.startsWith(child.path + "/")
|
||||
: hasActiveChild(child, pathname);
|
||||
return (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
title={child.label}
|
||||
onClick={() => handleItemClick(child)}
|
||||
className={`light-sidebar-subitem ${childActive ? "light-sidebar-subitem--active" : ""}`}
|
||||
>
|
||||
<ChildIcon className="light-sidebar-subitem__icon h-4 w-4" />
|
||||
<span className="light-sidebar-subitem__label">{child.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版浅色整树手风琴侧边栏。仅用于 wujie 嵌入环境(顶栏被宿主隐藏时保证所有层级可达)。
|
||||
*/
|
||||
function LegacyAccordionSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: PageSidebarProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = filterMenuItemsByRole(sidebarMenuItems, !!isAdmin);
|
||||
const visibleMenuItems = useSidebarMenuItems(!!isAdmin);
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
// defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变)
|
||||
@@ -67,9 +266,6 @@ export function PageSidebar({
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const iconButtonClass = isDark
|
||||
? "rounded-lg p-2 text-slate-400 transition-all duration-300 ease-out hover:scale-105 hover:bg-slate-800/50 hover:text-white"
|
||||
: "rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
|
||||
const panelClass = isDark
|
||||
? "ml-4 space-y-1 border-l border-blue-800/30 pl-4"
|
||||
: "ml-4 space-y-1 border-l border-red-100 pl-4";
|
||||
@@ -78,8 +274,8 @@ export function PageSidebar({
|
||||
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
|
||||
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
|
||||
const menuButtonClass = (isActive: boolean, depth = 0) =>
|
||||
`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
|
||||
`group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3"
|
||||
} ${
|
||||
isActive
|
||||
? isDark
|
||||
@@ -115,7 +311,14 @@ export function PageSidebar({
|
||||
onClick={() => item.path && navigate(item.path)}
|
||||
className={menuButtonClass(isActive, depth)}
|
||||
>
|
||||
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
|
||||
{isActive && (
|
||||
<span
|
||||
className={`absolute left-0 inset-y-0 w-[3px] rounded-r ${
|
||||
isDark ? "bg-cyan-300" : "bg-red-600"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<Icon className={`${depth === 0 ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105`} />
|
||||
{(!collapsed || depth > 0) && (
|
||||
<span className={labelClass(false, depth > 0)}>
|
||||
{item.label}
|
||||
@@ -145,7 +348,7 @@ export function PageSidebar({
|
||||
className={menuButtonClass(false, depth)}
|
||||
>
|
||||
<Icon
|
||||
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
|
||||
className={`${isTopLevel ? "h-[18px] w-[18px]" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out group-hover:scale-105 ${
|
||||
expanded && depth > 0 ? "scale-105" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useKnowledgeNotes } from "@/core/knowledge/hooks";
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
|
||||
import { SOURCE_TYPE_LABELS, formatDateTime } from "./wikiUtils";
|
||||
|
||||
/** What was clicked: a tag (exact filter) or an entity (relation-graph match). */
|
||||
export type RelatedNotesFilter =
|
||||
| { kind: "tag"; value: string }
|
||||
| { kind: "entity"; value: string };
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function useDebounced<T>(value: T, delay = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => setDebounced(value), delay);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a tag / entity chip → list every other note that shares it.
|
||||
* Server-side keyword search + pagination so huge graphs stay usable:
|
||||
* tags use the backend's exact `tag` filter, entities the exact `entity`
|
||||
* filter (note→entity relations), both combinable with `keyword`.
|
||||
*/
|
||||
export function RelatedNotesDialog({
|
||||
filter,
|
||||
currentNoteId,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
}: {
|
||||
filter: RelatedNotesFilter | null;
|
||||
currentNoteId?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (noteId: string) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounced(search.trim());
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
// New chip or new search term → back to the first page.
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [filter?.kind, filter?.value, debouncedSearch]);
|
||||
// Reset the search box whenever a different chip is opened.
|
||||
useEffect(() => {
|
||||
setSearch("");
|
||||
}, [filter?.kind, filter?.value]);
|
||||
|
||||
const params = {
|
||||
...(filter?.kind === "tag" ? { tag: filter.value } : {}),
|
||||
...(filter?.kind === "entity" ? { entity: filter.value } : {}),
|
||||
keyword: debouncedSearch || undefined,
|
||||
limit: filter ? PAGE_SIZE : 0,
|
||||
offset: page * PAGE_SIZE,
|
||||
};
|
||||
|
||||
const { data, isLoading, isFetching } = useKnowledgeNotes(params);
|
||||
const items = (data?.items ?? []).filter(
|
||||
(n: KnowledgeNote) => n.id !== currentNoteId,
|
||||
);
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<Dialog open={!!filter} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{filter?.kind === "entity" ? (
|
||||
<Sparkles className="size-4 text-violet-500" />
|
||||
) : (
|
||||
<Hash className="size-4 text-rose-500" />
|
||||
)}
|
||||
<span className="truncate">{filter?.value}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{filter?.kind === "entity"
|
||||
? "提到该实体的关联知识"
|
||||
: "使用该标签的关联知识"}
|
||||
{total > 0 ? ` · 共 ${total} 条` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="在关联知识中搜索标题/内容..."
|
||||
className="h-9 pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-[50vh]">
|
||||
{isLoading || (isFetching && items.length === 0) ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> 加载中...
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-6 text-center text-sm">
|
||||
{debouncedSearch ? "没有匹配的关联知识。" : "暂无其他关联知识。"}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 pr-2">
|
||||
{items.map((note) => (
|
||||
<li key={note.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(note.id)}
|
||||
className="flex w-full flex-col gap-0.5 rounded-md border border-transparent px-3 py-2 text-left transition-colors hover:border-slate-200 hover:bg-slate-50 dark:hover:border-slate-700 dark:hover:bg-slate-800/60"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||
<FileText className="size-3.5 shrink-0 text-slate-400" />
|
||||
<span className="truncate">{note.title}</span>
|
||||
</span>
|
||||
{note.summary ? (
|
||||
<span className="line-clamp-2 pl-[1.375rem] text-xs text-slate-500 dark:text-slate-400">
|
||||
{note.summary}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-muted-foreground pl-[1.375rem] text-[11px]">
|
||||
{SOURCE_TYPE_LABELS[note.source_type] ?? note.source_type}
|
||||
{note.updated_at
|
||||
? ` · ${formatDateTime(note.updated_at).slice(0, 10)}`
|
||||
: ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{pageCount > 1 ? (
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
第 {page + 1} / {pageCount} 页
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page === 0 || isFetching}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" /> 上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= pageCount - 1 || isFetching}
|
||||
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
>
|
||||
下一页 <ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,10 @@ import { DocumentToolbar } from "./DocumentToolbar";
|
||||
import { FloatingToc } from "./FloatingToc";
|
||||
import { MarkdownViewer } from "./MarkdownViewer";
|
||||
import { RelatedDocs } from "./RelatedDocs";
|
||||
import {
|
||||
RelatedNotesDialog,
|
||||
type RelatedNotesFilter,
|
||||
} from "./RelatedNotesDialog";
|
||||
import {
|
||||
SOURCE_TYPE_LABELS,
|
||||
STATUS_LABELS,
|
||||
@@ -91,6 +95,10 @@ export function WikiContent({
|
||||
const [draftContent, setDraftContent] = useState(note.content_md);
|
||||
const [draftStatus, setDraftStatus] = useState<string>(note.status);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
|
||||
// Tag / entity chip clicked → show notes that share it.
|
||||
const [relatedFilter, setRelatedFilter] = useState<RelatedNotesFilter | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Pull the backend-injected `## Related` wikilinks out of the body so they
|
||||
// render as a structured module; editing still works on the full source.
|
||||
@@ -270,12 +278,15 @@ export function WikiContent({
|
||||
<MetaTag>创建:{formatDateTime(note.created_at).slice(0, 10)}</MetaTag>
|
||||
) : null}
|
||||
{note.tags.map((tag) => (
|
||||
<span
|
||||
<button
|
||||
key={tag}
|
||||
className="inline-flex h-6 items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 dark:bg-rose-500/10 dark:text-rose-300"
|
||||
type="button"
|
||||
title={`查看「${tag}」的关联知识`}
|
||||
onClick={() => setRelatedFilter({ kind: "tag", value: tag })}
|
||||
className="inline-flex h-6 cursor-pointer items-center rounded-md bg-rose-50 px-2 text-xs font-medium text-rose-500 transition-colors hover:bg-rose-100 hover:text-rose-600 dark:bg-rose-500/10 dark:text-rose-300 dark:hover:bg-rose-500/20"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -287,11 +298,15 @@ export function WikiContent({
|
||||
{note.entities.map((ent) => {
|
||||
const meta = entityMeta(ent.entity_type);
|
||||
return (
|
||||
<span
|
||||
<button
|
||||
key={`${ent.name}-${ent.entity_type ?? ""}`}
|
||||
title={meta.label}
|
||||
type="button"
|
||||
title={`查看与「${ent.name}」(${meta.label})相关的知识`}
|
||||
onClick={() =>
|
||||
setRelatedFilter({ kind: "entity", value: ent.name })
|
||||
}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium",
|
||||
"inline-flex cursor-pointer items-center gap-1.5 rounded-full border py-1 pl-2 pr-1 text-xs font-medium transition-opacity hover:opacity-80",
|
||||
meta.className,
|
||||
)}
|
||||
>
|
||||
@@ -300,7 +315,7 @@ export function WikiContent({
|
||||
<span className="rounded-full bg-current/15 px-1.5 py-0.5 text-[10px] leading-none opacity-90">
|
||||
{meta.label}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -373,6 +388,18 @@ export function WikiContent({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RelatedNotesDialog
|
||||
filter={relatedFilter}
|
||||
currentNoteId={note.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRelatedFilter(null);
|
||||
}}
|
||||
onSelect={(id) => {
|
||||
setRelatedFilter(null);
|
||||
navigate(`/page/workspace/knowledge/${id}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Copy,
|
||||
Menu,
|
||||
Network,
|
||||
@@ -41,7 +40,7 @@ function HeaderButton({
|
||||
);
|
||||
}
|
||||
|
||||
/** Light 56px wiki header: logo + product name on the left, action entries on the right. */
|
||||
/** Light 56px wiki header: plain page title on the left, action entries on the right. */
|
||||
export function WikiHeader({
|
||||
onToggleSidebar,
|
||||
onImportFile,
|
||||
@@ -68,14 +67,8 @@ export function WikiHeader({
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
<div className="flex size-9 items-center justify-center rounded-[9px] bg-red-600 text-white">
|
||||
<BookOpen className="size-5" />
|
||||
</div>
|
||||
<div className="leading-none">
|
||||
<div className="text-base font-extrabold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
问答梳理
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-emerald-500">LLM Wiki · 问答梳理中心</div>
|
||||
<div className="text-base font-semibold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
问答梳理
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
BotIcon,
|
||||
CopyIcon,
|
||||
EyeOffIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
@@ -529,7 +530,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
来自他人
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && agent.published && !agent.is_private && (
|
||||
{(isOwned || isAdmin) && !isBuiltin && agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
@@ -539,6 +540,16 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
{(isOwned || isAdmin) && !isBuiltin && !agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-400/40 text-zinc-500 dark:text-zinc-400 text-xs"
|
||||
title="该智能体尚未发布,仅所有者可见"
|
||||
>
|
||||
<EyeOffIcon className="size-3" />
|
||||
未发布
|
||||
</Badge>
|
||||
)}
|
||||
{agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
@@ -15,11 +15,12 @@ import {
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
Trash2Icon,
|
||||
UploadCloudIcon,
|
||||
UserRoundIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
|
||||
import { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||
import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet";
|
||||
import { PublishSquarePickerDialog } from "@/components/workspace/publish-square-picker-dialog";
|
||||
@@ -57,33 +59,12 @@ import {
|
||||
type UpdateAgentRequest,
|
||||
} from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { isAgentSkillCandidate } from "@/core/skills";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Turn the admin-authored SOUL.md markdown into a clean, readable profile blurb
|
||||
* for the narrow panel. The view mode is a profile card, not a document view —
|
||||
* so we strip markdown syntax (headings, emphasis, inline code, list bullets,
|
||||
* blockquotes) rather than rendering it, keeping the line structure intact.
|
||||
*/
|
||||
function cleanProfileText(raw: string): string {
|
||||
return raw
|
||||
.split("\n")
|
||||
.map((line) =>
|
||||
line
|
||||
.replace(/^\s{0,3}#{1,6}\s*/, "")
|
||||
.replace(/^\s{0,3}>\s?/, "")
|
||||
.replace(/^(\s*)[-*+]\s+/, "$1• ")
|
||||
.replace(/(\*\*|__)(.*?)\1/g, "$2")
|
||||
.replace(/(\*|_)(.*?)\1/g, "$2")
|
||||
.replace(/`([^`]+)`/g, "$1"),
|
||||
)
|
||||
.join("\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Local edit-mode draft — committed in one `updateAgent` call on 保存. */
|
||||
interface AgentDraft {
|
||||
@@ -94,6 +75,7 @@ interface AgentDraft {
|
||||
skills: string[];
|
||||
published: boolean;
|
||||
featured: boolean;
|
||||
templateName: string;
|
||||
}
|
||||
|
||||
function draftFromAgent(agent: Agent): AgentDraft {
|
||||
@@ -105,6 +87,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
|
||||
skills: agent.skills ?? [],
|
||||
published: agent.published,
|
||||
featured: agent.featured_order != null,
|
||||
templateName: agent.template_name ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,18 +159,22 @@ export function AgentDetailPanel({
|
||||
const setFeatured = useSetAgentFeatured();
|
||||
const { data: publishSquares } = usePublishSquares();
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
// Same pool the edit dialog offers: only globally enabled skills are
|
||||
// whitelist candidates.
|
||||
const enabledPool = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[skills],
|
||||
);
|
||||
|
||||
// Permission model mirrors agent-card: admins manage the full catalog,
|
||||
// owners manage their own agents, everyone else is read-only.
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
|
||||
// 可配置的技能限定为「内置 + 自己的 + 已发布到广场的」,
|
||||
// 其他用户未发布的自定义技能不可选(管理员例外)。
|
||||
const enabledPool = useMemo(
|
||||
() =>
|
||||
skills.filter((skill) =>
|
||||
isAgentSkillCandidate(skill, currentUserId, isAdmin),
|
||||
),
|
||||
[skills, currentUserId, isAdmin],
|
||||
);
|
||||
const isOwned =
|
||||
!!agent &&
|
||||
!agent.builtin &&
|
||||
@@ -199,14 +186,30 @@ export function AgentDetailPanel({
|
||||
|
||||
const isSaving = updateAgent.isPending || setFeatured.isPending;
|
||||
|
||||
// 对话生成文件(md/txt 等 artifact)并在右侧展开预览时,自动收起智能体
|
||||
// 信息面板给预览让位;新文件落地时同样收起。编辑中不打断用户,用户随时
|
||||
// 可手动重新展开。
|
||||
const artifactsCtx = useArtifactsOptional();
|
||||
const artifactCount = artifactsCtx?.artifacts.length ?? 0;
|
||||
const artifactPanelVisible = !!artifactsCtx?.open && artifactCount > 0;
|
||||
const prevArtifactPanelVisible = useRef(false);
|
||||
const prevArtifactCount = useRef(0);
|
||||
useEffect(() => {
|
||||
const opened = artifactPanelVisible && !prevArtifactPanelVisible.current;
|
||||
const grew =
|
||||
artifactPanelVisible && artifactCount > prevArtifactCount.current;
|
||||
prevArtifactPanelVisible.current = artifactPanelVisible;
|
||||
prevArtifactCount.current = artifactCount;
|
||||
if ((opened || grew) && !editing) {
|
||||
setCollapsed(true);
|
||||
}
|
||||
}, [artifactPanelVisible, artifactCount, editing]);
|
||||
|
||||
const displayName = desensitize(agent?.name ?? agentName);
|
||||
const questions = useMemo(
|
||||
() => normalizeAgentQuestions(agent?.common_questions),
|
||||
[agent?.common_questions],
|
||||
);
|
||||
const soulPreview = agent?.soul?.trim()
|
||||
? cleanProfileText(desensitize(agent.soul))
|
||||
: undefined;
|
||||
|
||||
function beginEdit() {
|
||||
if (!agent || !canEdit) return;
|
||||
@@ -280,6 +283,8 @@ export function AgentDetailPanel({
|
||||
if (draft.description !== (agent.description ?? ""))
|
||||
request.description = draft.description;
|
||||
if (draft.soul !== (agent.soul ?? "")) request.soul = draft.soul;
|
||||
if (draft.templateName.trim() !== (agent.template_name ?? ""))
|
||||
request.template_name = draft.templateName.trim();
|
||||
if (nextPublished !== agent.published) request.published = nextPublished;
|
||||
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
|
||||
request.skills = draft.skills;
|
||||
@@ -422,14 +427,7 @@ export function AgentDetailPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="hidden"
|
||||
onClick={beginEdit}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Button size="sm" variant="outline" onClick={beginEdit}>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
@@ -528,9 +526,31 @@ export function AgentDetailPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 入库模板名 — view mode shows a hint row when configured. */}
|
||||
{!editing && agent.template_name?.trim() && (
|
||||
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
|
||||
<UploadCloudIcon className="size-3 shrink-0" />
|
||||
入库模板:{desensitize(agent.template_name)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publish / featured toggles — only meaningful in edit mode. */}
|
||||
{editing && draft && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="bg-background/60 space-y-1 rounded-lg border px-3 py-2">
|
||||
<p className="text-sm font-medium">入库模板名</p>
|
||||
<Input
|
||||
value={draft.templateName}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, templateName: e.currentTarget.value })
|
||||
}
|
||||
placeholder="例如:临时策略库(留空表示不支持入库)"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">公开发布</p>
|
||||
@@ -564,41 +584,31 @@ export function AgentDetailPanel({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── SOUL / 人设 ────────────────────────────────────────────────── */}
|
||||
{(editing || soulPreview) && (
|
||||
<Section
|
||||
icon={<SparklesIcon className="size-3.5" />}
|
||||
title="人设(SOUL)"
|
||||
>
|
||||
{editing && draft ? (
|
||||
<Textarea
|
||||
value={draft.soul}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, soul: e.currentTarget.value })
|
||||
}
|
||||
placeholder="输入 SOUL.md 内容"
|
||||
className="max-h-[45vh] min-h-48 resize-y font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
// Viewport-relative cap: the SOUL preview expands with the screen
|
||||
// (taller than the old fixed 240px box) but never swallows the
|
||||
// whole panel — long content still scrolls inside.
|
||||
<p className="text-muted-foreground max-h-[40vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{soulPreview}
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
|
||||
{(editing || questions.length > 0) && (
|
||||
{(editing || questions.length > 0 || canEdit) && (
|
||||
<Section
|
||||
icon={<MessageCircleQuestionIcon className="size-3.5" />}
|
||||
title="常用问题"
|
||||
extra={
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{(editing && draft ? draft.questions : questions).length} 个
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{(editing && draft ? draft.questions : questions).length} 个
|
||||
</span>
|
||||
{!editing && canEdit && (
|
||||
<Tooltip content="管理常用问题(新增 / 删除 / 排序)">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={beginEdit}
|
||||
aria-label="管理常用问题"
|
||||
>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{editing && draft ? (
|
||||
@@ -607,6 +617,15 @@ export function AgentDetailPanel({
|
||||
onChange={(next) => setDraft({ ...draft, questions: next })}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
) : questions.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={beginEdit}
|
||||
className="text-muted-foreground hover:text-foreground hover:border-border w-full rounded-lg border border-dashed px-3 py-4 text-center text-xs transition-colors"
|
||||
>
|
||||
<PlusIcon className="mr-1 inline size-3.5 align-[-2px]" />
|
||||
添加常用问题
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{questions.map((question, index) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||
import { ArrowDownUpIcon, BotIcon, Loader2, LockIcon, PlusIcon, ShieldIcon, XIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -29,6 +30,8 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [viewMode, setViewMode] = useViewMode("agent");
|
||||
const [activeTab, setActiveTab] = useState<string>("mine");
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -58,15 +61,21 @@ export function AgentGallery() {
|
||||
// If the active square tab disappears (admin removed that square), fall back
|
||||
// to "我的" so the gallery never points at a dead tab.
|
||||
useEffect(() => {
|
||||
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
|
||||
if (activeTab === "builtin" && !isAdmin) {
|
||||
setActiveTab("mine");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeTab !== "mine" &&
|
||||
activeTab !== "private" &&
|
||||
activeTab !== "builtin" &&
|
||||
squareTabs.length > 0 &&
|
||||
!squareTabs.some((sq) => sq.id === activeTab)
|
||||
) {
|
||||
setActiveTab("mine");
|
||||
}
|
||||
}, [activeTab, squareTabs]);
|
||||
}, [activeTab, squareTabs, isAdmin]);
|
||||
|
||||
const { data: privateStatus } = usePrivateSquareStatus();
|
||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||
@@ -80,9 +89,16 @@ export function AgentGallery() {
|
||||
const [passwordInput, setPasswordInput] = useState("");
|
||||
|
||||
// Only the active tab fetches. "广场" maps to the server "square" scope
|
||||
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
|
||||
const isSquareTab = activeTab !== "mine" && activeTab !== "private";
|
||||
const mainScope = activeTab === "mine" ? "mine" : "square";
|
||||
// (built-in/published, never private), "我的" to "mine" (owned + favorited),
|
||||
// "内置" to "builtin" (admin-only, user_id IS NULL).
|
||||
const isSquareTab =
|
||||
activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin";
|
||||
const mainScope =
|
||||
activeTab === "mine"
|
||||
? "mine"
|
||||
: activeTab === "builtin"
|
||||
? "builtin"
|
||||
: "square";
|
||||
const mainPage = pages[activeTab] ?? 1;
|
||||
const {
|
||||
agents: mainAgents,
|
||||
@@ -112,7 +128,7 @@ export function AgentGallery() {
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page: pages.private,
|
||||
page: pages.private ?? 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab === "private",
|
||||
owner: publisher?.id,
|
||||
@@ -190,16 +206,21 @@ export function AgentGallery() {
|
||||
list: Agent[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
tab: string;
|
||||
emptyDescription: string;
|
||||
showNewButton?: boolean;
|
||||
}) => {
|
||||
const { list, total, isLoading, tab, emptyDescription, showNewButton } = opts;
|
||||
const { list, total, isLoading, isFetching, tab, emptyDescription, showNewButton } =
|
||||
opts;
|
||||
// keepPreviousData keeps the old page visible while a new page loads, so
|
||||
// only show the spinner on the very first load (no rows yet).
|
||||
if (isLoading && list.length === 0) {
|
||||
// this full-height spinner only covers the very first load (no rows yet),
|
||||
// or a filter change while the previous result was already empty (where
|
||||
// isLoading stays false but there is nothing to keep on screen).
|
||||
if ((isLoading || isFetching) && list.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
);
|
||||
@@ -208,8 +229,29 @@ export function AgentGallery() {
|
||||
return renderEmpty(emptyDescription, showNewButton);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{renderGrid(list)}
|
||||
<div className="relative flex flex-col gap-4">
|
||||
{/* While a server-side search/tag/page query is in flight the stale
|
||||
keepPreviousData rows stay mounted — dim them and float a spinner
|
||||
on top so a slow backend query never looks like the filter was
|
||||
ignored, and block clicks on results that are about to change. */}
|
||||
<div
|
||||
className={
|
||||
isFetching
|
||||
? "pointer-events-none opacity-40 transition-opacity duration-200"
|
||||
: "transition-opacity duration-200"
|
||||
}
|
||||
aria-busy={isFetching}
|
||||
>
|
||||
{renderGrid(list)}
|
||||
</div>
|
||||
{isFetching && (
|
||||
<div className="absolute inset-0 z-10 flex items-start justify-center pt-24">
|
||||
<div className="bg-background/90 text-muted-foreground flex items-center gap-2 rounded-full border px-4 py-2 text-sm shadow-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在查询…
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
page={pages[tab] ?? 1}
|
||||
pageSize={PAGE_SIZE}
|
||||
@@ -315,6 +357,7 @@ export function AgentGallery() {
|
||||
list: privateAgents,
|
||||
total: privateTotal,
|
||||
isLoading: isPrivateLoading,
|
||||
isFetching: isPrivateFetching,
|
||||
tab: "private",
|
||||
emptyDescription: "私密广场暂时还没有智能体。",
|
||||
});
|
||||
@@ -366,6 +409,12 @@ export function AgentGallery() {
|
||||
私密广场
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<TabsTrigger value="builtin" title="内置/功能型智能体(仅管理员可见)">
|
||||
<BotIcon className="mr-1 h-3.5 w-3.5" />
|
||||
内置智能体
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
@@ -444,6 +493,7 @@ export function AgentGallery() {
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
isFetching: mainFetching,
|
||||
tab: sq.id,
|
||||
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
|
||||
})}
|
||||
@@ -455,12 +505,26 @@ export function AgentGallery() {
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
isFetching: mainFetching,
|
||||
tab: "mine",
|
||||
emptyDescription: t.agents.emptyDescription,
|
||||
showNewButton: true,
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{isAdmin && (
|
||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
isFetching: mainFetching,
|
||||
tab: "builtin",
|
||||
emptyDescription: "暂时没有内置智能体。",
|
||||
})}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{privateFeatureAvailable && (
|
||||
<TabsContent
|
||||
value="private"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRightIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { AgentQuestion } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Chips shown before the rest folds behind the 更多问题 toggle. */
|
||||
const VISIBLE_COUNT = 4;
|
||||
|
||||
/**
|
||||
* 常用问题 prompt chips on the agent chat landing screen — light-gray
|
||||
* borderless pills stacked left-aligned between the welcome hero and the
|
||||
* input box. Clicking a chip sends the question's full prompt through the
|
||||
* page's existing submit flow. Lists longer than {@link VISIBLE_COUNT} fold
|
||||
* behind a 更多问题 toggle; an empty list renders nothing.
|
||||
*/
|
||||
export function AgentQuestionChips({
|
||||
questions,
|
||||
onAsk,
|
||||
disabled,
|
||||
className,
|
||||
}: {
|
||||
questions: AgentQuestion[];
|
||||
onAsk: (prompt: string) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (questions.length === 0) return null;
|
||||
|
||||
const visible = expanded ? questions : questions.slice(0, VISIBLE_COUNT);
|
||||
const foldedCount = questions.length - VISIBLE_COUNT;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col items-start gap-2 px-1", className)}>
|
||||
{visible.map((question, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onAsk(question.prompt)}
|
||||
title={desensitize(question.prompt)}
|
||||
className={cn(
|
||||
"group bg-muted text-foreground/90 flex max-w-full items-center gap-2 rounded-full px-4 py-2.5 text-left text-sm leading-5 transition",
|
||||
"hover:bg-accent active:scale-[0.98]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{desensitize(question.title)}</span>
|
||||
<ArrowRightIcon className="text-muted-foreground size-3.5 shrink-0 transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
))}
|
||||
{foldedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 rounded-full px-4 py-1.5 text-xs transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
收起
|
||||
<ChevronUpIcon className="size-3.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
更多问题({foldedCount})
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,10 +12,20 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { AgentQuestion } from "@/core/agents";
|
||||
|
||||
/** Soft cap on the chip label length — beyond this the landing chip truncates. */
|
||||
const TITLE_SOFT_LIMIT = 80;
|
||||
|
||||
/** The text a question row would actually persist as (panel save falls back
|
||||
* title↔prompt), used for duplicate detection. */
|
||||
function effectivePrompt(q: AgentQuestion): string {
|
||||
return (q.prompt.trim() || q.title.trim()).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Editable list of an agent's 常用问题 (recommended questions). Each row has a
|
||||
* short `title` (the chip label) and the full `prompt` sent on click. Supports
|
||||
* add / remove / inline edit / reorder (move up & down). Pure controlled
|
||||
* add / remove / inline edit / reorder (move up & down), with non-blocking
|
||||
* hints for over-long titles and duplicate questions. Pure controlled
|
||||
* component — persistence stays with the caller (edit dialog or detail panel).
|
||||
*/
|
||||
export function AgentQuestionEditor({
|
||||
@@ -52,7 +62,13 @@ export function AgentQuestionEditor({
|
||||
尚未配置常用问题,点击下方按钮添加。
|
||||
</p>
|
||||
)}
|
||||
{value.map((item, index) => (
|
||||
{value.map((item, index) => {
|
||||
const titleLength = item.title.trim().length;
|
||||
const prompt = effectivePrompt(item);
|
||||
const duplicateOf = prompt
|
||||
? value.findIndex((other, i) => i < index && effectivePrompt(other) === prompt)
|
||||
: -1;
|
||||
return (
|
||||
<div key={index} className="bg-background/60 space-y-1.5 rounded-lg border p-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
@@ -103,8 +119,19 @@ export function AgentQuestionEditor({
|
||||
onChange={(e) => update(index, { prompt: e.currentTarget.value })}
|
||||
className="min-h-16 resize-none text-sm"
|
||||
/>
|
||||
{titleLength > TITLE_SOFT_LIMIT && (
|
||||
<p className="text-destructive text-xs">
|
||||
标题建议不超过 {TITLE_SOFT_LIMIT} 字(当前 {titleLength} 字),过长会在聊天区被截断。
|
||||
</p>
|
||||
)}
|
||||
{duplicateOf >= 0 && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
与第 {duplicateOf + 1} 条问题内容重复。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -29,6 +29,8 @@ interface AgentSelectorProps {
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
disabled?: boolean;
|
||||
/** 紧凑模式(CM 参考图):触发器改为灰底胶囊 + 主色机器人图标。 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,6 +46,7 @@ export function AgentSelector({
|
||||
selectedAgentId,
|
||||
onAgentChange,
|
||||
disabled,
|
||||
compact = false,
|
||||
}: AgentSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
@@ -76,12 +79,17 @@ export function AgentSelector({
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none transition-colors",
|
||||
selectedAgent
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
compact
|
||||
? "border-transparent bg-gray-50/60 text-gray-600 hover:bg-gray-100/80 hover:text-gray-800 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
: selectedAgent
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
<BotIcon className="size-3" />
|
||||
<BotIcon
|
||||
className={cn("size-3", compact && "text-[var(--primary)]")}
|
||||
/>
|
||||
<span className="max-w-[140px] truncate text-xs font-normal">
|
||||
{triggerLabel}
|
||||
</span>
|
||||
|
||||
@@ -5,14 +5,16 @@ import {
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
FileType2Icon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
UploadCloudIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -35,10 +37,11 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
openArtifactInNewTab,
|
||||
} from "@/core/artifacts/download";
|
||||
import {
|
||||
useArtifactContent,
|
||||
@@ -86,6 +89,13 @@ function extractTextFromChildren(children: ReactNode): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 取 md 标题用于入库:优先文中第一个标题行,否则退回文件名(去扩展名)。 */
|
||||
function extractMarkdownTitle(content: string, filepath: string): string {
|
||||
const heading = /^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/m.exec(content);
|
||||
if (heading?.[1]) return heading[1].trim();
|
||||
return getFileName(filepath).replace(/\.(md|markdown)$/i, "");
|
||||
}
|
||||
|
||||
function findScrollableArtifactElement(root: HTMLElement | null) {
|
||||
if (!root) {
|
||||
return null;
|
||||
@@ -111,12 +121,19 @@ export function ArtifactFileDetail({
|
||||
filepath: filepathFromProps,
|
||||
threadId,
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
}: {
|
||||
className?: string;
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
* 圆桌沙箱(Step2/Step3)用:流式结束后,把 `write-file:` 虚拟 md 预览切回真实
|
||||
* artifacts API(可加载 + PATCH 保存),从而支持用户编辑(对齐主聊天页 md 可编辑)。
|
||||
* 默认 false —— 主聊天页等其它调用方行为不变。
|
||||
*/
|
||||
allowVirtualMarkdownEdit?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
@@ -131,10 +148,6 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
return filepathFromProps;
|
||||
}, [filepathFromProps, isWriteFileVirtual]);
|
||||
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API。 */
|
||||
const useWriteFileStreaming = useMemo(() => {
|
||||
return isWriteFileVirtual && !isImageFile(filepath);
|
||||
}, [filepath, isWriteFileVirtual]);
|
||||
const isSkillFile = useMemo(() => {
|
||||
return filepath.endsWith(".skill");
|
||||
}, [filepath]);
|
||||
@@ -143,6 +156,24 @@ export function ArtifactFileDetail({
|
||||
const isSpreadsheetFile = useMemo(() => {
|
||||
return /\.xlsx$/i.test(filepath);
|
||||
}, [filepath]);
|
||||
// 圆桌沙箱:write_file 虚拟预览的 md 在流式结束(thread 非 loading)后,切回真实
|
||||
// artifacts API 加载并允许编辑/保存。流式进行中仍走内存 stub 只读预览;仅
|
||||
// allowVirtualMarkdownEdit 开启的调用方(Step2/Step3 沙箱)生效,主聊天页不受影响。
|
||||
const editVirtualMarkdown = useMemo(() => {
|
||||
return (
|
||||
allowVirtualMarkdownEdit &&
|
||||
isWriteFileVirtual &&
|
||||
!isImageFile(filepath) &&
|
||||
!isMock &&
|
||||
!thread.isLoading &&
|
||||
checkCodeFile(filepath).language === "markdown"
|
||||
);
|
||||
}, [allowVirtualMarkdownEdit, isWriteFileVirtual, filepath, isMock, thread.isLoading]);
|
||||
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API;
|
||||
* 圆桌结束后可编辑的 md(editVirtualMarkdown)亦回到真实 API(可加载 + PATCH 保存)。 */
|
||||
const useWriteFileStreaming = useMemo(() => {
|
||||
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
|
||||
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
||||
const { isCodeFile, language } = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
let nextLanguage = checkCodeFile(filepath).language;
|
||||
@@ -163,7 +194,10 @@ export function ArtifactFileDetail({
|
||||
}, [language]);
|
||||
const { content, isApplyingEdit } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||||
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
||||
// 不会被旧的内存 stub 值覆盖回退。
|
||||
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
|
||||
enabled: isCodeFile && !useWriteFileStreaming,
|
||||
});
|
||||
|
||||
@@ -439,6 +473,50 @@ export function ArtifactFileDetail({
|
||||
updateArtifact,
|
||||
]);
|
||||
|
||||
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
|
||||
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
||||
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
|
||||
const { agent: routeAgent } = useAgent(routeAgentId ?? null);
|
||||
const ingestTemplateName = routeAgent?.template_name?.trim() ?? "";
|
||||
const canPublishToKnowledge =
|
||||
!!routeAgentId &&
|
||||
!!ingestTemplateName &&
|
||||
language === "markdown" &&
|
||||
!isSkillFile &&
|
||||
!isMock;
|
||||
const [isPublishingToKnowledge, setIsPublishingToKnowledge] = useState(false);
|
||||
const handlePublishToKnowledge = useCallback(async () => {
|
||||
if (!routeAgentId || isPublishingToKnowledge) {
|
||||
return;
|
||||
}
|
||||
const source = (displayContent ?? "").trim();
|
||||
if (!source) {
|
||||
toast.error("Markdown 内容为空,无法发布入库");
|
||||
return;
|
||||
}
|
||||
setIsPublishingToKnowledge(true);
|
||||
try {
|
||||
const result = await publishArtifactToKnowledge({
|
||||
agent_id: routeAgentId,
|
||||
title: extractMarkdownTitle(source, filepath),
|
||||
content: source,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success(`已发布入库:${result.node_name}`);
|
||||
} else {
|
||||
const last = result.attempts[result.attempts.length - 1];
|
||||
toast.error(
|
||||
`发布入库失败:${last?.error ?? last?.response_text ?? "接口返回失败"}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to publish artifact to knowledge base:", error);
|
||||
toast.error(error instanceof Error ? error.message : "发布入库失败");
|
||||
} finally {
|
||||
setIsPublishingToKnowledge(false);
|
||||
}
|
||||
}, [displayContent, filepath, isPublishingToKnowledge, routeAgentId]);
|
||||
|
||||
const [isExportingWord, setIsExportingWord] = useState(false);
|
||||
const handleDownloadAsWord = useCallback(async () => {
|
||||
const source = (displayContent ?? "").trim();
|
||||
@@ -459,30 +537,20 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
}, [displayContent, filepath]);
|
||||
|
||||
const handleOpenWriteFileInNewTab = useCallback(() => {
|
||||
/** HTML 产物在新窗口打开(结果绘制汇总/大屏多页都需要全屏看)。
|
||||
* blob: URL 带 charset=utf-8(与预览/下载一致,防中文乱码);URL 延迟回收,
|
||||
* 给新窗口足够的加载时间。 */
|
||||
const handleOpenInNewWindow = useCallback(() => {
|
||||
const source = displayContent ?? "";
|
||||
if (!source.trim()) {
|
||||
toast.error("文件内容为空,暂时无法打开");
|
||||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||||
return;
|
||||
}
|
||||
if (language === "mermaid") {
|
||||
void openMermaidSvgInNewTab(source).catch((error) => {
|
||||
console.error("Failed to open mermaid artifact:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open artifact");
|
||||
});
|
||||
return;
|
||||
}
|
||||
const blobType =
|
||||
language === "html"
|
||||
? "text/html;charset=utf-8"
|
||||
: language === "markdown"
|
||||
? "text/markdown;charset=utf-8"
|
||||
: "text/plain;charset=utf-8";
|
||||
const blob = new Blob([source], { type: blobType });
|
||||
const blob = new Blob([source], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}, [displayContent, language]);
|
||||
}, [displayContent]);
|
||||
|
||||
const handleDownloadWriteFile = useCallback(() => {
|
||||
const source = displayContent ?? "";
|
||||
@@ -516,7 +584,7 @@ export function ArtifactFileDetail({
|
||||
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
|
||||
<ArtifactHeader className="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="bg-muted/65 inline-flex min-w-0 items-center rounded-full border border-black/6 p-1 shadow-sm dark:border-white/8">
|
||||
<span className="bg-background text-muted-foreground inline-flex h-7 shrink-0 items-center rounded-full border border-black/6 px-2.5 text-[10px] font-semibold tracking-[0.16em] dark:border-white/8">
|
||||
@@ -546,7 +614,7 @@ export function ArtifactFileDetail({
|
||||
</ArtifactTitle>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex w-full items-center justify-center md:w-auto md:flex-1">
|
||||
{isSupportPreview && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ToggleGroup
|
||||
@@ -589,8 +657,17 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||
<div className="flex w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
|
||||
<ArtifactActions className="bg-muted/45 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
|
||||
{canPublishToKnowledge && (
|
||||
<ArtifactAction
|
||||
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
|
||||
label="发布入库"
|
||||
tooltip={`发布到知识库(模板:${ingestTemplateName})`}
|
||||
disabled={isPublishingToKnowledge || !displayContent}
|
||||
onClick={() => void handlePublishToKnowledge()}
|
||||
/>
|
||||
)}
|
||||
{canEditArtifact && (
|
||||
<ArtifactAction
|
||||
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
|
||||
@@ -614,45 +691,6 @@ export function ArtifactFileDetail({
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={SquareArrowOutUpRightIcon}
|
||||
label={t.common.openInNewWindow}
|
||||
tooltip={t.common.openInNewWindow}
|
||||
onClick={async () => {
|
||||
if (useWriteFileStreaming) {
|
||||
handleOpenWriteFileInNewTab();
|
||||
return;
|
||||
}
|
||||
if (language === "mermaid") {
|
||||
try {
|
||||
await openMermaidSvgInNewTab(displayContent ?? "");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to open artifact",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openArtifactInNewTab({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to open artifact",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isCodeFile && (
|
||||
<ArtifactAction
|
||||
icon={CopyIcon}
|
||||
@@ -679,6 +717,15 @@ export function ArtifactFileDetail({
|
||||
onClick={handleDownloadAsWord}
|
||||
/>
|
||||
)}
|
||||
{language === "html" && (
|
||||
<ArtifactAction
|
||||
icon={ExternalLinkIcon}
|
||||
label="新窗口打开"
|
||||
tooltip="在新窗口全屏打开 HTML"
|
||||
disabled={!displayContent?.trim()}
|
||||
onClick={handleOpenInNewWindow}
|
||||
/>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* 紧凑模式(正常 App)新对话落地页的品牌欢迎块 —— 按 CM-assistant 参考图重画。
|
||||
* 仅展示,无交互。配色走主题 token(--workspace-brand / --primary),红/蓝/深蓝主题自适应。
|
||||
* 非紧凑模式仍用 <Welcome/>,本组件不参与。
|
||||
*/
|
||||
export function CompactWelcome({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full flex-col items-center justify-center text-center select-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* CM logo:浅底圆环 + 主色渐变圆角方块 */}
|
||||
<div
|
||||
className="mb-3.5 inline-flex items-center justify-center rounded-full p-3"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--workspace-brand) 5%, transparent)",
|
||||
border:
|
||||
"1px solid color-mix(in srgb, var(--workspace-brand) 12%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex size-14 items-center justify-center rounded-2xl shadow-md"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to top right, var(--workspace-brand), color-mix(in srgb, var(--workspace-brand) 55%, white))",
|
||||
}}
|
||||
>
|
||||
<span className="text-2xl font-black tracking-wide text-white italic">
|
||||
CM
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="flex items-center justify-center gap-2 text-2xl font-bold tracking-tight text-foreground/90">
|
||||
<span className="text-2xl">👋</span> 欢迎使用CM助手
|
||||
</h1>
|
||||
<p className="text-muted-foreground mx-auto mt-2 max-w-sm text-xs leading-relaxed">
|
||||
基于先进深度推理模型,为您提供高效的市场分析、专业报告撰写、调研设计与协同脑暴支持。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
/** 常用智能体最多展示个数(需求 1.2 ②)。 */
|
||||
const MAX_FREQUENT = 3;
|
||||
|
||||
/** 「更多」跳转目标:智能体管理页(需求 1.3)。 */
|
||||
const MORE_HREF = "/page/strategy/agents";
|
||||
|
||||
/** 打开某个智能体主页(其对话落地页,thread_id=new = 新会话)。 */
|
||||
function agentHomeHref(agentId: string): string {
|
||||
return `/page/strategy/agents/${agentId}/chats/new`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「常用智能体」侧栏区块(通用问答左栏,全量模式)。
|
||||
*
|
||||
* 级联兜底取数(需求 1.2 ② / 1.3)——按优先级补足到最多 3 个、去重:
|
||||
* 1. 置顶智能体(``is_pinned``,可来自「我的」或「广场」);
|
||||
* 2. 不足 3 个 → 用「我的」智能体(``scope="mine"``)补足;
|
||||
* 3. 仍不足 → 用「广场」默认推荐(``scope="square"``)补足。
|
||||
*
|
||||
* 取不到任何智能体时整块隐藏(与 ``RecentChatList`` 的空态约定一致)。
|
||||
*/
|
||||
export function FrequentAgentsList() {
|
||||
const pathname = usePathname();
|
||||
const { agents: mine } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const { agents: square } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
const frequent = useMemo<Agent[]>(() => {
|
||||
const out: Agent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (agent: Agent | undefined) => {
|
||||
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
|
||||
seen.add(agent.id);
|
||||
out.push(agent);
|
||||
};
|
||||
// 1. 置顶组优先(够 3 个则下面我的/广场都不再补)。
|
||||
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
|
||||
// 2. 我的智能体补足。
|
||||
mine.forEach(add);
|
||||
// 3. 广场默认推荐补足。
|
||||
square.forEach(add);
|
||||
return out;
|
||||
}, [mine, square]);
|
||||
|
||||
if (frequent.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
常用智能体
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{frequent.map((agent) => {
|
||||
const href = agentHomeHref(agent.id);
|
||||
const isActive = pathname === href;
|
||||
return (
|
||||
<SidebarMenuItem key={agent.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
asChild
|
||||
className={
|
||||
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
|
||||
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
|
||||
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none"
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap"
|
||||
href={href}
|
||||
>
|
||||
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">{desensitize(agent.name)}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center justify-between gap-1"
|
||||
href={MORE_HREF}
|
||||
>
|
||||
<span>更多</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,44 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Database } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
/**
|
||||
* Per-conversation toggle for knowledge-base RAG injection (phase 2).
|
||||
*
|
||||
* Writes ``context.knowledge_rag_enabled`` into the thread settings, which the
|
||||
* run forwards as ``runtime.context`` so ``KnowledgeRagMiddleware`` injects
|
||||
* recalled knowledge before each model call for this conversation only.
|
||||
*/
|
||||
export function KnowledgeRagToggle({ threadId }: { threadId: string }) {
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
const enabled = settings.context.knowledge_rag_enabled === true;
|
||||
|
||||
const toggle = () => {
|
||||
setSettings("context", {
|
||||
...settings.context,
|
||||
knowledge_rag_enabled: !enabled,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content={enabled ? "知识库召回:已开启" : "知识库召回:已关闭"}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggle}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
enabled && "text-primary hover:text-primary",
|
||||
)}
|
||||
>
|
||||
<Database />
|
||||
知识库
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
export function KnowledgeRagToggle({
|
||||
className: _className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
void _className;
|
||||
return null;
|
||||
}
|
||||
@@ -129,6 +129,7 @@ function useArtifactResolver(threadId: string): (path: string) => string {
|
||||
export function MessageListItem({
|
||||
className,
|
||||
fitRowToContent = false,
|
||||
anchorId,
|
||||
message,
|
||||
isLoading,
|
||||
threadId,
|
||||
@@ -140,6 +141,11 @@ export function MessageListItem({
|
||||
}: {
|
||||
className?: string;
|
||||
fitRowToContent?: boolean;
|
||||
/**
|
||||
* Stable DOM id so the question navigator can scrollIntoView to this
|
||||
* message. `scroll-mt` keeps the target clear of the overlay header.
|
||||
*/
|
||||
anchorId?: string;
|
||||
message: Message;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
@@ -156,10 +162,12 @@ export function MessageListItem({
|
||||
'';
|
||||
return (
|
||||
<AIElementMessage
|
||||
id={anchorId}
|
||||
className={cn(
|
||||
'group/conversation-message relative',
|
||||
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
|
||||
isHuman && fitRowToContent && 'ml-auto',
|
||||
anchorId && 'scroll-mt-20',
|
||||
className,
|
||||
)}
|
||||
from={isHuman ? 'user' : 'assistant'}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
@@ -35,6 +36,7 @@ import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { getQuestionAnchorId } from "../question-navigator";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
|
||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||
@@ -51,6 +53,38 @@ import { SubtaskCard } from "./subtask-card";
|
||||
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
|
||||
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
|
||||
|
||||
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
|
||||
function ScrollToBottomOnUserMessage({
|
||||
threadId,
|
||||
messages,
|
||||
}: {
|
||||
threadId: string;
|
||||
messages: Message[];
|
||||
}) {
|
||||
const { scrollToBottom } = useStickToBottomContext();
|
||||
const prevHumanCountRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
prevHumanCountRef.current = null;
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const humanCount = messages.reduce(
|
||||
(count, message) => count + (message.type === "human" ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
const prev = prevHumanCountRef.current;
|
||||
prevHumanCountRef.current = humanCount;
|
||||
if (prev !== null && humanCount > prev) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => scrollToBottom());
|
||||
});
|
||||
}
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
className,
|
||||
contentClassName,
|
||||
@@ -265,6 +299,7 @@ export function MessageList({
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
|
||||
@@ -297,6 +332,11 @@ export function MessageList({
|
||||
<MessageListItem
|
||||
key={`${group.id}/${msg.id}`}
|
||||
fitRowToContent={fitMessageRowsToContent}
|
||||
anchorId={
|
||||
msg.type === "human"
|
||||
? getQuestionAnchorId(msg, messages.indexOf(msg))
|
||||
: undefined
|
||||
}
|
||||
message={msg}
|
||||
isLoading={thread.isLoading}
|
||||
threadId={threadId}
|
||||
@@ -750,6 +790,9 @@ const WRITING_MODE_LABELS: Record<string, string> = {
|
||||
loose: "可结合通用知识",
|
||||
};
|
||||
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
|
||||
general: "通用检索",
|
||||
notebook: "我的空间",
|
||||
// 旧值(历史会话回显兼容)
|
||||
knowledge_base: "知识库",
|
||||
skill: "技能检索",
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ function classifySpeed(tps: number): { color: string; label: string } {
|
||||
return { color: "#ef4444", label: "缓慢" };
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
const mins = Math.floor(ms / 60_000);
|
||||
@@ -31,7 +31,7 @@ function formatDuration(ms: number): string {
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
function SpeedPill({ metric }: { metric: MessageMetric }) {
|
||||
export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
||||
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
||||
const { color, label } = classifySpeed(metric.tokensPerSec);
|
||||
const display =
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { ChevronsRightIcon, ListIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
} from "@/core/messages/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
/** Width of the expanded floating panel, in px. */
|
||||
const PANEL_WIDTH = 300;
|
||||
/** Minimum side gutter (beyond the panel itself) required to float the panel. */
|
||||
const PANEL_GUTTER_MARGIN = 24;
|
||||
/** Max characters kept in a question summary card. */
|
||||
const SUMMARY_MAX_LENGTH = 80;
|
||||
/** Questions needed before the navigator shows up at all. */
|
||||
const MIN_QUESTIONS = 2;
|
||||
const COLLAPSE_STORAGE_KEY = "deerflow.question-navigator.collapsed";
|
||||
|
||||
/**
|
||||
* Stable DOM anchor id for a message. Prefers the server-side message id and
|
||||
* falls back to the render index so messages without ids still get a target
|
||||
* (requirement: `message-${index}` style fallback).
|
||||
*/
|
||||
export function getQuestionAnchorId(
|
||||
message: Pick<Message, "id">,
|
||||
index: number,
|
||||
): string {
|
||||
return `message-${message.id ?? index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the raw markdown of a user question into a short plain-text summary:
|
||||
* strips upload/artifact/page-context envelopes and common Markdown syntax,
|
||||
* collapses whitespace, and truncates with an ellipsis.
|
||||
*/
|
||||
function summarizeQuestion(raw: string): string {
|
||||
let text = stripArtifactTag(stripUploadedFilesTag(raw));
|
||||
text = text
|
||||
.replace(/<page_context>[\s\S]*?<\/page_context>/gi, " ")
|
||||
.replace(/```[\s\S]*?(```|$)/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/`([^`]*)`/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*>\s?/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*\d+\.\s+/gm, "")
|
||||
.replace(/(\*\*|__|~~)/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (text.length > SUMMARY_MAX_LENGTH) {
|
||||
return `${text.slice(0, SUMMARY_MAX_LENGTH)}…`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
type QuestionItem = {
|
||||
anchorId: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
/** Walk up from a message anchor to the element that actually scrolls. */
|
||||
function findScrollParent(el: HTMLElement | null): HTMLElement | null {
|
||||
let node = el?.parentElement ?? null;
|
||||
while (node) {
|
||||
const overflowY = window.getComputedStyle(node).overflowY;
|
||||
if (
|
||||
(overflowY === "auto" || overflowY === "scroll") &&
|
||||
node.scrollHeight > node.clientHeight
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readCollapsedPreference(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.localStorage.getItem(COLLAPSE_STORAGE_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatGPT-style question index floating at the right edge of the conversation
|
||||
* column. Reads questions straight from the live `messages` array (no global
|
||||
* state copy), so it stays in sync with streaming, history switches and new
|
||||
* turns for free.
|
||||
*
|
||||
* Layout contract: render inside a `relative` ancestor that spans the chat
|
||||
* content area, and pass the inset via `className` (e.g. `top-16 bottom-48
|
||||
* left-0 right-0`). The component measures its own width: when the side gutter
|
||||
* next to the centered message column is wide enough it floats an expandable
|
||||
* panel there, vertically centered in the available chat area; on narrower
|
||||
* viewports it degrades to a small pill button that opens a right-side drawer,
|
||||
* so it never covers the chat body or input box.
|
||||
*/
|
||||
export function QuestionNavigator({
|
||||
messages,
|
||||
className,
|
||||
contentMaxWidth = 1024,
|
||||
}: {
|
||||
messages: Message[];
|
||||
className?: string;
|
||||
/** Max width (px) of the centered message column, used to size the gutter. */
|
||||
contentMaxWidth?: number;
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const [panelFits, setPanelFits] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(readCollapsedPreference);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const questions = useMemo(() => {
|
||||
const items: QuestionItem[] = [];
|
||||
messages.forEach((message, index) => {
|
||||
if (message.type !== "human") return;
|
||||
const summary = summarizeQuestion(extractContentFromMessage(message));
|
||||
if (!summary) return;
|
||||
items.push({ anchorId: getQuestionAnchorId(message, index), summary });
|
||||
});
|
||||
return items;
|
||||
}, [messages]);
|
||||
|
||||
const visible = questions.length >= MIN_QUESTIONS;
|
||||
|
||||
// Decide between floating panel and drawer mode from the actual gutter width
|
||||
// next to the centered message column — not from the browser viewport — so
|
||||
// opening the artifact preview or the sidebar reflows the mode correctly.
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
const update = () => {
|
||||
const gutter = (root.clientWidth - contentMaxWidth) / 2;
|
||||
setPanelFits(gutter >= PANEL_WIDTH + PANEL_GUTTER_MARGIN);
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(root);
|
||||
return () => observer.disconnect();
|
||||
}, [visible, contentMaxWidth]);
|
||||
|
||||
// Highlight the question nearest the top of the chat viewport. A plain
|
||||
// rAF-throttled scroll listener over a handful of anchors is cheaper and
|
||||
// more predictable here than juggling IntersectionObserver thresholds.
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
const firstAnchor = document.getElementById(questions[0]!.anchorId);
|
||||
const scroller = findScrollParent(firstAnchor);
|
||||
if (!scroller) return;
|
||||
|
||||
let raf = 0;
|
||||
const update = () => {
|
||||
raf = 0;
|
||||
const viewportTop = scroller.getBoundingClientRect().top;
|
||||
let current = questions[0]!.anchorId;
|
||||
for (const question of questions) {
|
||||
const el = document.getElementById(question.anchorId);
|
||||
if (!el) continue;
|
||||
if (el.getBoundingClientRect().top - viewportTop <= 140) {
|
||||
current = question.anchorId;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setActiveId(current);
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (!raf) raf = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
scroller.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
scroller.removeEventListener("scroll", onScroll);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [visible, questions]);
|
||||
|
||||
const scrollToQuestion = useCallback((anchorId: string) => {
|
||||
document
|
||||
.getElementById(anchorId)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
setActiveId(anchorId);
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
window.localStorage.setItem(COLLAPSE_STORAGE_KEY, next ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage unavailable (private mode) — keep in-memory state only.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const pillButton = (label: string, onClick: () => void) => (
|
||||
<Tooltip content={label}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-background/80 pointer-events-auto h-8 gap-1.5 rounded-full px-3 shadow-md backdrop-blur-md"
|
||||
onClick={onClick}
|
||||
>
|
||||
<ListIcon className="size-3.5" />
|
||||
<span className="text-xs">问题列表</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{questions.length}
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn("pointer-events-none absolute z-20", className)}
|
||||
>
|
||||
<div className="absolute inset-y-0 right-3 flex items-center justify-end">
|
||||
{panelFits ? (
|
||||
collapsed ? (
|
||||
pillButton("展开问题导航", toggleCollapsed)
|
||||
) : (
|
||||
<div
|
||||
className="border-border/60 bg-background/85 pointer-events-auto flex max-h-full flex-col overflow-hidden rounded-xl border shadow-lg backdrop-blur-md"
|
||||
style={{ width: PANEL_WIDTH }}
|
||||
>
|
||||
<div className="border-border/50 flex shrink-0 items-center gap-2 border-b px-3 py-2">
|
||||
<Tooltip content="收起问题导航">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<ChevronsRightIcon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<span className="text-xs font-medium">本轮问题</span>
|
||||
<span className="text-muted-foreground ml-auto text-xs tabular-nums">
|
||||
{questions.length}
|
||||
</span>
|
||||
</div>
|
||||
<QuestionCardList
|
||||
questions={questions}
|
||||
activeId={activeId}
|
||||
onSelect={scrollToQuestion}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{pillButton("打开问题导航", () => setSheetOpen(true))}
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-[320px] flex-col gap-0 p-0 sm:max-w-[320px]"
|
||||
>
|
||||
<SheetHeader className="border-border/50 border-b px-4 py-3">
|
||||
<SheetTitle className="text-sm">
|
||||
本轮问题({questions.length})
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<QuestionCardList
|
||||
questions={questions}
|
||||
activeId={activeId}
|
||||
onSelect={(anchorId) => {
|
||||
setSheetOpen(false);
|
||||
scrollToQuestion(anchorId);
|
||||
}}
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionCardList({
|
||||
questions,
|
||||
activeId,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
questions: QuestionItem[];
|
||||
activeId: string | null;
|
||||
onSelect: (anchorId: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const itemRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
|
||||
// Keep the active card visible as the chat scrolls. `block: "nearest"` only
|
||||
// nudges this inner list — the card is already inside the on-screen panel,
|
||||
// so no outer container moves.
|
||||
useEffect(() => {
|
||||
if (!activeId) return;
|
||||
itemRefs.current.get(activeId)?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeId]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="问题导航"
|
||||
className={cn("flex flex-col gap-2 overflow-y-auto p-2", className)}
|
||||
>
|
||||
{questions.map((question, index) => {
|
||||
const isActive = question.anchorId === activeId;
|
||||
return (
|
||||
<button
|
||||
key={question.anchorId}
|
||||
ref={(el) => {
|
||||
if (el) itemRefs.current.set(question.anchorId, el);
|
||||
else itemRefs.current.delete(question.anchorId);
|
||||
}}
|
||||
type="button"
|
||||
title={question.summary}
|
||||
onClick={() => onSelect(question.anchorId)}
|
||||
className={cn(
|
||||
"w-full cursor-pointer rounded-lg border px-3 py-2 text-left text-[13px] leading-5 shadow-xs transition-colors",
|
||||
isActive
|
||||
? "border-primary/40 border-l-primary bg-primary/10 text-foreground border-l-2 font-medium"
|
||||
: "border-border/50 bg-background/60 text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-start gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-[11px] leading-5 tabular-nums",
|
||||
isActive ? "text-primary" : "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{index + 1}.
|
||||
</span>
|
||||
<span className="line-clamp-2 break-words">
|
||||
{question.summary}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
@@ -71,18 +73,32 @@ import {
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RecentChatList() {
|
||||
export function RecentChatList({
|
||||
compact = false,
|
||||
limit,
|
||||
moreHref,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
/** 最多展示的会话条数;省略则不限制(需求 1.3:通用问答最多 10 条)。 */
|
||||
limit?: number;
|
||||
/** 「更多」入口跳转目标;省略则不展示「更多」行。 */
|
||||
moreHref?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||
);
|
||||
// 紧凑模式下整列由 WorkspaceSidebar 统一收起,列表本身始终展开、不显示标题折叠钮。
|
||||
const collapsed = compact ? false : internalCollapsed;
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
if (compact) return;
|
||||
const next = !internalCollapsed;
|
||||
setInternalCollapsed(next);
|
||||
localStorage.setItem("sidebar:recent-chats-collapsed", String(next));
|
||||
};
|
||||
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } =
|
||||
@@ -185,21 +201,35 @@ export function RecentChatList() {
|
||||
if (visibleThreads.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const shownThreads =
|
||||
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
className={cn(
|
||||
"text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none flex items-center justify-between transition-colors",
|
||||
!compact && "cursor-pointer hover:text-muted-foreground",
|
||||
)}
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
||||
? t.sidebar.recentChats
|
||||
: t.sidebar.demoChats}
|
||||
<span className="flex items-center gap-2">
|
||||
<span>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
|
||||
? t.sidebar.recentChats
|
||||
: t.sidebar.demoChats}
|
||||
</span>
|
||||
{compact && (
|
||||
<span className="text-muted-foreground bg-muted rounded-full px-1.5 py-0.5 text-[10px] font-normal tracking-normal tabular-nums">
|
||||
{visibleThreads.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
{!compact && (
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
@@ -208,7 +238,7 @@ export function RecentChatList() {
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{visibleThreads.map((thread) => {
|
||||
{shownThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
@@ -223,13 +253,26 @@ export function RecentChatList() {
|
||||
className={
|
||||
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
|
||||
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
|
||||
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none"
|
||||
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none" +
|
||||
(compact
|
||||
? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
|
||||
: "")
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{compact && (
|
||||
<MessageSquare
|
||||
className={
|
||||
"size-3.5 shrink-0 " +
|
||||
(isActive
|
||||
? "text-[var(--primary)]"
|
||||
: "text-muted-foreground/70")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
@@ -338,6 +381,22 @@ export function RecentChatList() {
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
{moreHref && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center justify-between gap-1"
|
||||
href={moreHref}
|
||||
>
|
||||
<span>{t.common.more}</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { DropletIcon, LayoutGridIcon, LayoutIcon, ListIcon, MoonIcon, PanelLeftIcon, SunIcon } from "lucide-react";
|
||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
@@ -25,6 +26,7 @@ import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useCitationDisplaySettings,
|
||||
useLayoutMode,
|
||||
useUpdateAppearanceSettings,
|
||||
useUpdateCitationDisplaySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
@@ -42,9 +44,10 @@ const languageOptions: { value: Locale; label: string }[] = [
|
||||
{ value: "zh-CN", label: zhCN.locale.localName },
|
||||
];
|
||||
|
||||
export function AppearanceSettingsPage() {
|
||||
export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const { t, locale, changeLocale } = useI18n();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const currentTheme = theme;
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
@@ -55,17 +58,32 @@ export function AppearanceSettingsPage() {
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||
const globalLayoutMode = appearance?.default_layout_mode ?? "full";
|
||||
// 有效界面模式:用户自己选过则以其为准,否则跟随管理员系统默认(再否则会话级回退)。
|
||||
const [effectiveLayoutMode, setLayoutMode] = useLayoutMode();
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
|
||||
const handleSetGlobalDefault = async (value: "light" | "dark") => {
|
||||
const handleSetGlobalDefault = async (
|
||||
value: "light" | "dark" | "lightblue",
|
||||
) => {
|
||||
if (value === globalDefault) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_theme: value });
|
||||
const label =
|
||||
value === "light" ? "浅色" : value === "dark" ? "深色" : "浅蓝";
|
||||
toast.success(`已将系统默认主题设置为${label}`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefaultLayoutMode = async (value: "full" | "compact") => {
|
||||
if (value === globalLayoutMode) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_layout_mode: value });
|
||||
toast.success(
|
||||
value === "light"
|
||||
? "已将系统默认主题设置为浅色"
|
||||
: "已将系统默认主题设置为深色",
|
||||
`已将系统默认界面模式设置为${value === "full" ? "全量模式" : "简洁模式"}`,
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
@@ -107,12 +125,20 @@ export function AppearanceSettingsPage() {
|
||||
description: t.settings.appearance.darkDescription,
|
||||
icon: MoonIcon,
|
||||
},
|
||||
{
|
||||
id: "lightblue",
|
||||
label: t.settings.appearance.lightblue,
|
||||
description: t.settings.appearance.lightblueDescription,
|
||||
icon: DropletIcon,
|
||||
},
|
||||
],
|
||||
[
|
||||
t.settings.appearance.dark,
|
||||
t.settings.appearance.darkDescription,
|
||||
t.settings.appearance.light,
|
||||
t.settings.appearance.lightDescription,
|
||||
t.settings.appearance.lightblue,
|
||||
t.settings.appearance.lightblueDescription,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -130,13 +156,86 @@ export function AppearanceSettingsPage() {
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
active={currentTheme === option.id}
|
||||
mode={option.id as "light" | "dark"}
|
||||
mode={option.id as "light" | "dark" | "lightblue"}
|
||||
onSelect={(value) => setTheme(value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
title="界面模式"
|
||||
description="全量模式展示侧边栏与顶栏导航;简洁模式隐藏侧边栏与顶栏,获得更大的工作区。"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={effectiveLayoutMode !== "compact" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLayoutMode("full");
|
||||
onClose?.();
|
||||
navigate("/page/workspace/chats/new");
|
||||
}}
|
||||
>
|
||||
<PanelLeftIcon className="mr-1.5 size-4" />
|
||||
全量模式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={effectiveLayoutMode === "compact" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setLayoutMode("compact");
|
||||
onClose?.();
|
||||
navigate("/page/workspace/chats/new");
|
||||
}}
|
||||
>
|
||||
<LayoutIcon className="mr-1.5 size-4" />
|
||||
简洁模式
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认界面模式(管理员)"
|
||||
description="给所有还没有手动切换过界面模式的用户的默认;已经自己切换过的用户保留其个人选择不受影响。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalLayoutMode === "full" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetDefaultLayoutMode("full")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<PanelLeftIcon className="mr-1.5 size-4" />
|
||||
全量模式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalLayoutMode === "compact" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetDefaultLayoutMode("compact")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<LayoutIcon className="mr-1.5 size-4" />
|
||||
简洁模式
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalLayoutMode === "full" ? "全量模式" : "简洁模式"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
@@ -165,9 +264,23 @@ export function AppearanceSettingsPage() {
|
||||
<MoonIcon className="mr-1.5 size-4" />
|
||||
深色
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "lightblue" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("lightblue")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<DropletIcon className="mr-1.5 size-4" />
|
||||
浅蓝
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalDefault === "light" ? "浅色" : "深色"}
|
||||
{globalDefault === "light"
|
||||
? "浅色"
|
||||
: globalDefault === "dark"
|
||||
? "深色"
|
||||
: "浅蓝"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
@@ -370,8 +483,8 @@ function ThemePreviewCard({
|
||||
label: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
mode: "light" | "dark";
|
||||
onSelect: (mode: "light" | "dark") => void;
|
||||
mode: "light" | "dark" | "lightblue";
|
||||
onSelect: (mode: "light" | "dark" | "lightblue") => void;
|
||||
}) {
|
||||
const previewMode = mode;
|
||||
return (
|
||||
@@ -401,14 +514,20 @@ function ThemePreviewCard({
|
||||
"relative overflow-hidden rounded-md border text-xs transition-colors",
|
||||
previewMode === "dark"
|
||||
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
||||
: "border-slate-200 bg-white text-slate-900",
|
||||
: previewMode === "lightblue"
|
||||
? "border-blue-200 bg-white text-slate-900"
|
||||
: "border-slate-200 bg-white text-slate-900",
|
||||
)}
|
||||
>
|
||||
<div className="border-border/50 flex items-center gap-2 border-b px-3 py-2">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
previewMode === "dark" ? "bg-emerald-400" : "bg-emerald-500",
|
||||
previewMode === "dark"
|
||||
? "bg-emerald-400"
|
||||
: previewMode === "lightblue"
|
||||
? "bg-blue-500"
|
||||
: "bg-emerald-500",
|
||||
)}
|
||||
/>
|
||||
<div className="h-2 w-10 rounded-full bg-current/20" />
|
||||
|
||||
@@ -121,7 +121,9 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
||||
<div className="min-w-0 space-y-8 p-6">
|
||||
{activeSection === "account" && <AccountSettingsPage />}
|
||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeSection === "appearance" && (
|
||||
<AppearanceSettingsPage onClose={() => props.onOpenChange?.(false)} />
|
||||
)}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
EyeOffIcon,
|
||||
FileArchive,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
LockIcon,
|
||||
PinIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
@@ -436,6 +438,18 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<Upload className="size-4" />
|
||||
{t.settings.skills.installSkill}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
router.push("/page/workspace/chats/new?mode=skill");
|
||||
}}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
title="在对话中描述需求,由 AI 帮你创建一个新技能"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
{t.settings.skills.createSkill}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<TagSearchBar
|
||||
@@ -736,7 +750,7 @@ export function SkillRow({
|
||||
{t.settings.skills.badgeFromOthers}
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && skill.published && (
|
||||
{(isOwned || isAdmin) && !isBuiltin && skill.published && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
@@ -746,6 +760,16 @@ export function SkillRow({
|
||||
{t.settings.skills.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
{(isOwned || isAdmin) && !isBuiltin && !skill.published && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-400/40 text-zinc-500 dark:text-zinc-400 text-xs"
|
||||
title={t.settings.skills.badgeUnpublishedTitle}
|
||||
>
|
||||
<EyeOffIcon className="size-3" />
|
||||
{t.settings.skills.badgeUnpublished}
|
||||
</Badge>
|
||||
)}
|
||||
{isCustom && !isBuiltin && skill.source === "agent" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Trophy } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -86,6 +86,20 @@ export function WorkspaceHeader({ className }: { className?: string }) {
|
||||
) : (
|
||||
titleBlock
|
||||
)}
|
||||
{/* 用户排行榜入口:紧挨左上角的应用名,新标签打开免登录的公开排行榜页。 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open("#/public/leaderboard", "_blank", "noopener")
|
||||
}
|
||||
title="打开用户排行榜公开页"
|
||||
className="text-muted-foreground hover:text-sidebar-foreground h-8 shrink-0 gap-1 px-1.5 text-xs font-medium"
|
||||
>
|
||||
<Trophy className="size-4" />
|
||||
<span>排行榜</span>
|
||||
</Button>
|
||||
<SidebarCollapseButton />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
import { WorkspaceNavStudio } from "./workspace-nav-studio";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -32,10 +33,19 @@ const NEW_CHAT_CLASS =
|
||||
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
|
||||
"dark:text-primary-foreground dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25";
|
||||
|
||||
// 紧凑模式(CM 参考图):白卡描边、居中、主色 + 图标
|
||||
const NEW_CHAT_COMPACT_CLASS =
|
||||
"h-9 justify-center gap-2 rounded-xl bg-card text-[13px] font-semibold text-foreground transition-colors " +
|
||||
"border border-[color-mix(in_srgb,var(--primary)_18%,transparent)] " +
|
||||
"hover:bg-[color-mix(in_srgb,var(--primary)_4%,transparent)] hover:text-[var(--primary)] hover:border-[color-mix(in_srgb,var(--primary)_32%,transparent)] " +
|
||||
"data-[active=true]:bg-card data-[active=true]:text-[var(--primary)] data-[active=true]:shadow-none";
|
||||
|
||||
export function WorkspaceNavChatList({
|
||||
showCollapseTrigger = false,
|
||||
compact = false,
|
||||
}: {
|
||||
showCollapseTrigger?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
@@ -59,21 +69,46 @@ export function WorkspaceNavChatList({
|
||||
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
||||
<SidebarMenu className="gap-1">
|
||||
<SidebarMenuItem>
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")}
|
||||
>
|
||||
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
{compact ? (
|
||||
// 紧凑模式:新对话占满整行白卡样式 + 右侧收起钮(点击收起整列历史栏)。
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(NEW_CHAT_COMPACT_CLASS, "min-w-0 flex-1")}
|
||||
>
|
||||
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
||||
<Plus className="stroke-[2.5] text-[var(--primary)]" />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/chats/new"}
|
||||
asChild
|
||||
className={cn(
|
||||
NEW_CHAT_CLASS,
|
||||
showCollapseTrigger && "min-w-0 flex-1",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
href="/page/workspace/chats/new"
|
||||
onClick={handleNewChatClick}
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{showCollapseTrigger && (
|
||||
<SidebarCollapseButton className="size-8 shrink-0 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
{!showCollapseTrigger && (
|
||||
<>
|
||||
@@ -161,6 +196,7 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<KnowledgeRagToggle className={NAV_ITEM_CLASS} />
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/knowledge")}
|
||||
|
||||
@@ -165,7 +165,7 @@ export function WorkspaceNavMenu() {
|
||||
Checkpoint 迁移
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
{isAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard")
|
||||
@@ -174,6 +174,15 @@ export function WorkspaceNavMenu() {
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { FrequentAgentsList } from "./frequent-agents-list";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
@@ -25,13 +27,58 @@ export function WorkspaceSidebar({
|
||||
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;其余 workspace 页保持原样。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
// sidebar-container 的 absolute/left-0 覆盖影响),外层 overflow-hidden 把它从
|
||||
// --sidebar-width 裁到 0、聊天区随宽度平滑回流;收起后整列不接收指针。
|
||||
if (compactChrome) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden={!isSidebarOpen}
|
||||
className={cn(
|
||||
"h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-out",
|
||||
isSidebarOpen ? "w-(--sidebar-width)" : "w-0 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-(--sidebar-width) border-r border-border",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
{isGeneralQa && <FrequentAgentsList />}
|
||||
<RecentChatList
|
||||
compact
|
||||
limit={isGeneralQa ? 10 : undefined}
|
||||
moreHref={isGeneralQa ? "/page/workspace/chats" : undefined}
|
||||
/>
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
collapsible="icon"
|
||||
// 紧凑模式:展开时为固定宽度整列(none,无内置折叠),收起由上面的条件渲染处理;
|
||||
// 非紧凑沿用 icon。
|
||||
collapsible={compactChrome ? "none" : "icon"}
|
||||
className={cn(
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
@@ -45,10 +92,15 @@ export function WorkspaceSidebar({
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
|
||||
{isSidebarOpen && <RecentChatList />}
|
||||
{isSidebarOpen && <RecentRoundtableList />}
|
||||
{isSidebarOpen && <RecentWritingList />}
|
||||
<WorkspaceNavChatList
|
||||
showCollapseTrigger={compactChrome}
|
||||
compact={compactChrome}
|
||||
/>
|
||||
{isSidebarOpen && <RecentChatList compact={compactChrome} />}
|
||||
{/* 「多智能体会商」「最近写作」与「智能体/技能」等菜单项保持一致的显隐:
|
||||
仅在非紧凑模式(!compactChrome)下展示,紧凑模式(有外层页面侧栏时)一并隐藏。 */}
|
||||
{isSidebarOpen && !compactChrome && <RecentRoundtableList />}
|
||||
{isSidebarOpen && !compactChrome && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
<SidebarFooter>
|
||||
|
||||
Reference in new issue
Block a user