Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc8b0e082 |
No files matched your search
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="188" height="241" viewBox="0 0 188 241"><defs><style>.a{fill:url(#a);}.b{fill:#fff;opacity:0.47;}</style><pattern id="a" preserveAspectRatio="xMidYMid slice" width="100%" height="100%" viewBox="0 0 376 482"><image width="376" height="482" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXgAAAHiCAYAAADiYNeDAAAgAElEQVR4nOy993csOZYmdoFIw6R73pav6qrqrvY9flatmdldrWa1+nv1m46OjrSr7ZnZdtW2ulxX1Xt8ho+eTBcB6CACQFzcQEQaMskkeb938iUzvP3w4cPFhdDpGK41hGg+ez3jxZm0veg69r85Vj0zzHqe/thnOmgJAIn9xh8zrQsAqwBwAwA2ybf7rNqPWbaH1nG/V+zfCZRX02y/BQBtO90gAwDz4KcAoOw0bacPAWAAAH3794n9xr/NZx99DiD8dutkdvv4k6F9Toae88Zc+LOk53umZj3fSec57/W7Imhd67NnnCc6loAdETsy7iKiXgOADQBYtyTvvjft9B4i9hW7Tfy7a6dhghcNNKBR0eYIfoRI3hH7iPw2n0NL7OZzhL7N9GNE8kO0PVdwDOw2GYyFggmesWiYZ+wmADwEgHsAcB8AbgPAHft9Cyl2R9DtyHfbbqtlCRx/5JznQMk/sftdt7+d4saf1H7G9jOKfA+Rot8FgB0AeGW/XwDASwB4BgB7dlsMxkLABM84K6zZzyr53LREPongO0t4J5yN1J5j3dEUBL9jSf6EfI7th8E4FdiDZw++wPwefMcS9+sA8BoAPAaABwDwyJL4LUT4MYsmmWIvlxlZjUXjSH3Xkv8WADwHgKcA8AQAvskLAq3ns3LYg59ve1cMrOAZs2DN2hfryCu/awk9RvC3llSZnycSVMDFMLIkHyN483sbeftH9sPqnjEVWMGzgi/QfJ7SEvYbAPCW/X7TEvl9a7GsEZume34HfyXgInSO0efAWjqG6L8CgK8B4M/2e6sxGocV/Hzbu2JggmeCLxCe5xoJVbxrSR0T/BuW4Ncu7qCvBY4twX9NCP5rq+5xiGap7Jng59veFQMTPBN8Ae2P5JFV5+8BwLcA4B077S6yZ9xn3ugVxmxQyJ5xn1fWzvkCAD4FgM+syt/K7yYT/Hzbu2JggmeCv5V/dB7tctcq9LcIwd+5kCNjTMIrQvB/tp9tEHl0zq79nC+Y4JcGTPDXl+DbVpl/GwC+CwAf2obSB6hz0Q0b9cJYXvSRRXNg7RzTQPtHAPgtAPzBqvrze9GZ4JcGTPDXi+BvothzZ8Vggn/AkVWXHqkleUzwzrrZRbH3iwMT/NKACf56ELywZP4hIvN3bPz6Lfu5sbC9My4C+8iieWmtHEf6f7Skvxj2Y4JfGjDBX12C71jv/I5V5h8AwHcswX9gY9YvsimOcX7QNrb+E0vwv7d/P7c+/qszzY3DBL80YIK/mgTfsw2kP7Af02D6trVnbrNav7bYtxaN+XxpG2Z/bT+fWj//9GCCXxowwV8dgu/ZKJh71o75DiL4tzhenUFwbCNuHMH/3to2L218/fxkzwS/NGCCvxoEv2oJ/ScA8GOr3h8ji2aeZFmMq48xsmieWhX/CwD4uW2cnS8lAhP80oAJ/vISfBcpdmO//NAS/I9suCN3QmLMAmXDK39pCf7XtmHWKfrh1Ntigl8aMMFfToJfsQ2lP7GfD23qgHuW9BmMebFtSf1rG23zc6vqP5natmGCXxowwV8egpcoIuZt6607S+ataNrd6/1sM6ZF/LnLrEf/C/txit5F3tQnOmOCXxowwV8OgheW1I398jcA8D0bx37XfkI7RgOzO2NGRJ8/ZRX9tiX33wDAz6yN82XtQ8YEvzTgXovLjRWr2E1UzPcB4C8swX9rqnS8zPGMaVBPktKmg76PQm3v2V7QH1sb55kdwISxhGAFv7wKftX67H8PAH9pOyi5wTTCqBiq2JnYGfMgeP6iin6MBiUxHab+DQD+P+vVn/ilWMEvDVjBLx82LIl/aP31v7fWzKPokeo5XyQGo/IsRX6EgqVtI7Ret436D2270GuW5J/ZkacYSwJW8Mul4Dcsmf8tAPydTQT2wPY+Jcely+Njz51x5rAK3j3O8ed6xyp6EzP/362a/xVok9WSFfwygBX8cmAzt190Tuh/Z332H+fT6QOMiR34AWYsCo6g3QMYVfS3Qefi4zXUqe6u7RW7ZVMjMC4QrOAvXsHfsSGPPwWRe+3fRjnZ64+HiZ1xnnDPdf3jfWgtmj+Chn8FgP/XhlZuz3SUrODPFKzgLw43rPL5nlXsPwUNH03MGcPEzrgI+OeullE37McFAty0ETcf20yWrOYvAKzgL0bB37Ahj39vPx/YRtTV6v6Z0BlLjPjzfuLVPMB/s5//MRXJs4I/U7CCP1/0rMIxqv1/tuT+gyixMxiXF+Z5ftdG2bgBZTZtR6knZ5aWmDERrODPT8EbcjcWzD9aYv++Jfu4JcPKnXGZUP/cH9sG199YJf9/2b/jnaNYwZ8pWMEvHm3bE/A7ltj/yWZ+vHXVT5zBsALmW2h4yFXrz5tImxfnOhj4NQQr+MUq+Jbt4m3i2v+97ZH6ejxChhU74yoh+l4d2vQGxo//P21em0/tQOEFWMGfKVjBLw63rQ9pyP0frHp/fFVPlsGYAhvWprxlLctNO+0z22mKccZgBb8YBX/bhj6ahtR/Z9MO3A+36zqOzLh9BuMywUdXVh70FzbH/H8FgP8bAP4lT0PMCv5MwQr+bNG2se0m3cB/sOT+0VSZHxmM64X7Nlx43fr0Kzbv/BP25c8OTPBnB5E3Jmn9H6wl82NryYTk7hUHSTnAYFwHhM971/bc3rD9QF6z3vwf+M04GzDBnw3MA/q+tWT+2XZiah46j6T4YDCuJCZbkCs2EMGp+Zb15//EmSlPDyb402PNhj3+bzYE8sNoCKSoYXL24BnXAqTNSVcefCOI/toSvbFv/g9r2Rzz8zE/mOBPh7vIb/9fbeclck01p/NlMCg0SpNd4pYNJe7aGSt2eMDZEpYxPJjg54fx1/8KAP5LniisGM6suJ5epWjQ+YAcPCgHgxGiYPg8uMZF2BTvSMt2CuzZTKumhvyvdhQpxoxggp8dLlLGhEH+R9uB6d18K1iMeGLX1z5Ui8GYCCFwu9SKbXztICX/M46wmR1M8LOhZcn8761y/+to56VAuTO5MxhRmPfETi/cmoov/5a1Pl2Kg/9mO0WlfEGnAxP89FixNoyJlPlfAOB/snmvS/jRlpjcGYypEH1HPNEnduzXjjVw2nbml7XJyhgBmOCnQ9fmbP+pVe4/CcjdKRGtUXw7kzuDMRWC90f44WCRon9g3701S/L/j42VH/EFbgYT/GSs2Tjdf7Ke+18FMe4Vtc7EzmDMDCyIzN9+iMCA5I0lmiHe+ozDKJvBBN+Mnm3R/0fbgemHlNwDr51j2hmMU0KXkcVVb/6OTd7Xs9xlrJvf8gAi9WCCr8ea7Z36T9Zz/4s8j7UAFNfOXjuDsRBUkvFZ30bnAsu8iwoApFX0n7CSj4MJPo5VG6b1TyDyHqo/tIMUWCDlDqzcGYwzBcrX5HnedYoq5t20HaI6dkHTGPs7OxYsA4EJvooVOwLNP1rl/qOA3B2xK0XqkWy/MxhnAvw+5danBpCymFbaNbfsuzm0S46skufoGgQm+BBda8uYbJD/2WaEDMndELvSIJxRKJjYGYwzBX6f3N+ZApCiIPqS5G/YoIcEEf0n9u9rD2CCD2Cqe++gOPcf2YE7CuTkbj9Bi/8FHzWDcdXh2ryU/ZZRJX9ifXnT0/VzDqEswARfoG3J/ac2K+Rf2Rb7Aki5c4Mqg3FBcO8hVJS8i65JLLFrS/LXPq0BE3xhsjy2oy/9J9tCf8/P9cpdlR0xGAzGxUAboSUsyQdK/p59d09sZI3x4r+67nVsJniAh1ax/2er4MuxU51iMP4fcKw7g3HhcDXoLCuiJEMlf9++wwYHVs1vXeebdt0J/o4l9/9ke8kV6Qe854eVO1szDMZSwJG8t2vAhVAK1ON11xL8f80H876muM4Ev5E3zmj9v9tG1df8HPP8qKwgeCDkzh48g7EEsFFsRsmb0aFkgg/pNftOg+0A9S/Xdfi/60rwa7bl3WWFfNvPCTx3YOXOYCwrvJK3bWMmjLKwaxL7Tpt3e8cq+Z9fx96u15HgE9tL9b/YiJl3/RyOlmEwLhmsko9H17xr33Gwja+/tA2w1wbXjeDbNjPkP9gcM98JrkHeS1UxuTMYlw35eyuKT0nwXfuOm2RkL62C/+w6hU9eN4J/3Q6Q/c8BueOqHm1QrY4yw2AwlgW006GyqQ3Kd9eN8frPtoer+XxxXe5f60p6zP6UfHIiYSNmfmzHUP2RbWQtkVfx1AUdMIPBOBsom2fS5q4psGHf+UMbNnloI2t00M52BbVc68q2IebtLi5fjLhtwyEdud8LlnPWjELqnZU7g7H8cO+pH5fBkLu1a0Bg0r5r3/0ta9n8DLQuwievcE6pq6vg89PKb/CKbWwx2SH/znZsssshYqfZIbnTKoNxORAMpqZRVI0uOkOFo0IZDjjynryGQTX3/NXBVffguzbHzN/aVAQf2lzvtvXd9lJVqmyNd+AskQzG5YEm728eHy+LmDkhHXuvWg4wETXfWKL//Cpnn7zqBH8HtOnVpv+xGDRbrBWTrcTPQyKzOJGzgmcwLgfo++uVfFa8w17BO5LXH+Q1ep178SalwZOreqevLsELWAWtv1tEzYh/VzSy2ichL+1tT1WqLine truncated
|
||||
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68"><defs><style>.a{fill:#fff;stroke:#707070;stroke-width:3.75px;opacity:0;}.b{fill:url(#a);}.c{stroke:none;}.d{fill:none;}</style><linearGradient id="a" x1="0.431" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f3212c"/><stop offset="1" stop-color="#aa071a"/></linearGradient></defs><g transform="translate(-71.077 -67.746)"><g class="a" transform="translate(71.077 67.746)"><rect class="c" width="68" height="68"/><rect class="d" x="1.875" y="1.875" width="64.25" height="64.25"/></g><path class="b" d="M114.406,106.791a7.707,7.707,0,0,0-4.354,1.353L98.7,99.937a13.3,13.3,0,0,0,1.742-6.583c0-.421-.03-.842-.06-1.263L112,85.207a4.687,4.687,0,1,0-1.952-2.345L99.691,88.995a13.678,13.678,0,0,0-6.1-7.365l2.072-10.341c.15,0,.3.03.451.03a4.66,4.66,0,1,0-4.655-4.659,4.727,4.727,0,0,0,1.412,3.367l-2.1,10.462A13.479,13.479,0,0,0,80.05,81.87l-6.4-5.712A6.129,6.129,0,1,0,71.7,78.443L77.677,83.7a13.456,13.456,0,0,0,.691,19.991l-5.316,5.622a5.948,5.948,0,0,0-3.394-1.022,6.163,6.163,0,1,0,6.156,6.162,6.343,6.343,0,0,0-.721-2.916l5.826-6.193a13.464,13.464,0,0,0,16.1-2.946l11.022,7.966a7.77,7.77,0,0,0-1.2,4.118,7.658,7.658,0,1,0,15.316,0A7.817,7.817,0,0,0,114.406,106.791Zm0,0" transform="translate(13 9.671)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68">
|
||||
<defs>
|
||||
<linearGradient id="ap-idea" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox">
|
||||
<stop offset="0" stop-color="#f3212c"/>
|
||||
<stop offset="1" stop-color="#aa071a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="url(#ap-idea)" transform="translate(12 10)">
|
||||
<path d="M22 2.2A12.8 12.8 0 0 0 9.2 15c0 5.1 2.9 9.5 7.1 11.6v3.8a2.4 2.4 0 0 0 2.4 2.4h6.6a2.4 2.4 0 0 0 2.4-2.4v-3.8A12.8 12.8 0 0 0 34.8 15 12.8 12.8 0 0 0 22 2.2Zm3.3 26.8h-6.6v-2.1h6.6Zm-3.3-26.8A10.4 10.4 0 0 1 32.4 15a10.4 10.4 0 0 1-6.3 9.5 2.4 2.4 0 0 0-1.3 2.1v.3h-5.6v-.3a2.4 2.4 0 0 0-1.3-2.1A10.4 10.4 0 0 1 11.6 15 10.4 10.4 0 0 1 22 2.2Z"/>
|
||||
<path d="M17.6 39.2a2 2 0 0 0 2 2h4.8a2 2 0 0 0 2-2v-1.4h-8.8Zm-1.8-3.8h12.4a1.6 1.6 0 1 0 0-3.2H15.8a1.6 1.6 0 1 0 0 3.2Z"/>
|
||||
<path d="M8.4 22.6a1.6 1.6 0 0 0 0-3.2H3.8a1.6 1.6 0 1 0 0 3.2Zm31.8 0a1.6 1.6 0 0 0 0-3.2h-4.6a1.6 1.6 0 1 0 0 3.2ZM7.2 10.8a1.6 1.6 0 0 0 2.3-2.3L6.2 5.3A1.6 1.6 0 0 0 4 7.6Zm29.3-2.3a1.6 1.6 0 0 0 2.3 2.3l3.2-3.2A1.6 1.6 0 1 0 39.7 5.3Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:url(#a);}</style><linearGradient id="a" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#f3212c"/><stop offset="1" stop-color="#aa071a"/></linearGradient></defs><g transform="translate(14482.893 -23044.904)"><rect class="a" width="68" height="68" transform="translate(-14482.893 23044.904)"/><path class="b" d="M43.013,57.336h-.255l-.074,0a14.064,14.064,0,0,1-8.911-3.467,6.216,6.216,0,0,1,6.1-5.029h6.278a6.218,6.218,0,0,1,6.1,5.03A14.073,14.073,0,0,1,43.6,57.324h-.009l-.067,0H43.5l-.063,0h-.424ZM30.159,54.9H8.5a1.717,1.717,0,0,1-1.715-1.716v-10.1A1.717,1.717,0,0,1,8.5,41.363H20.6a1.717,1.717,0,0,0,1.716-1.715V37.73H11.824a1.717,1.717,0,0,1-1.716-1.715V8.8a1.718,1.718,0,0,1,1.716-1.716h8.768V3.437A3.44,3.44,0,0,1,24.029,0h6.93A3.441,3.441,0,0,1,34.4,3.437V7.089H42.87A1.718,1.718,0,0,1,44.586,8.8V26.022c-.515-.046-1.044-.07-1.574-.07A17.327,17.327,0,0,0,30.157,54.9Zm4.219-36.07a3.437,3.437,0,1,0,3.438,3.437A3.441,3.441,0,0,0,34.378,18.828Zm-13.619,0A3.437,3.437,0,1,0,24.2,22.265,3.441,3.441,0,0,0,20.758,18.828ZM24.029,3.437V7.083h6.924V3.437Zm7.256,47.59v0a14.059,14.059,0,1,1,23.456,0,9.508,9.508,0,0,0-8.589-5.455H44.686a7.193,7.193,0,1,0-3.347,0H39.873a9.5,9.5,0,0,0-8.587,5.457ZM43.013,42.5a3.923,3.923,0,1,1,3.923-3.924A3.928,3.928,0,0,1,43.013,42.5ZM6.844,31.07a1.717,1.717,0,0,1-1.716-1.715V15.176a1.715,1.715,0,1,1,3.431,0V29.355A1.717,1.717,0,0,1,6.844,31.07ZM49.8,27.327h0a17.187,17.187,0,0,0-3.429-1.051v-11.1a1.715,1.715,0,1,1,3.431,0v12.15Zm3.406-.007a1.717,1.717,0,0,1-1.715-1.715V18.724a1.715,1.715,0,0,1,3.431,0v6.881A1.717,1.717,0,0,1,53.206,27.32Zm-51.491,0A1.717,1.717,0,0,1,0,25.605V18.724a1.715,1.715,0,0,1,3.431,0v6.881A1.717,1.717,0,0,1,1.715,27.32Z" transform="translate(-14477.664 23050.15)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="68" height="68" viewBox="0 0 68 68">
|
||||
<defs>
|
||||
<linearGradient id="ap-task" x1="0.405" x2="1" y2="1" gradientUnits="objectBoundingBox">
|
||||
<stop offset="0" stop-color="#f3212c"/>
|
||||
<stop offset="1" stop-color="#aa071a"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g fill="url(#ap-task)" transform="translate(14 12)">
|
||||
<path d="M28.4 2.2H15.6A3.4 3.4 0 0 0 12.2 5.6v2.8H8.6A3.4 3.4 0 0 0 5.2 11.8v28.8A3.4 3.4 0 0 0 8.6 44h22.8a3.4 3.4 0 0 0 3.4-3.4V11.8a3.4 3.4 0 0 0-3.4-3.4h-3.6V5.6A3.4 3.4 0 0 0 28.4 2.2Zm-12.8 3.4h12.8v2.8H15.6Zm19.2 35.8H8.6V11.8h3.6v2.2a1.6 1.6 0 0 0 1.6 1.6h12.8a1.6 1.6 0 0 0 1.6-1.6v-2.2h3.6Z"/>
|
||||
<rect x="11.4" y="24.2" width="3.6" height="11.6" rx="1.2"/>
|
||||
<rect x="18.2" y="20.4" width="3.6" height="15.4" rx="1.2"/>
|
||||
<rect x="25" y="27.2" width="3.6" height="8.6" rx="1.2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 924 B |
@@ -18,7 +18,9 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import type { StreamdownProps } from "streamdown";
|
||||
|
||||
import { SafeStreamdown } from "@/core/streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
@@ -302,11 +304,11 @@ export const MessageBranchPage = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
export type MessageResponseProps = StreamdownProps;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
<SafeStreamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className,
|
||||
|
||||
@@ -34,9 +34,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { PromptInputFilePart } from "@/core/uploads";
|
||||
import { splitUnsupportedUploadFiles } from "@/core/uploads";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import type { PromptInputFilePart, UploadPolicy } from "@/core/uploads";
|
||||
import {
|
||||
DEFAULT_UPLOAD_POLICY,
|
||||
splitUnsupportedUploadFiles,
|
||||
useUploadPolicy,
|
||||
validateChatUploadFiles,
|
||||
} from "@/core/uploads";
|
||||
import { imeEnterAction } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
@@ -57,6 +62,7 @@ import {
|
||||
Children,
|
||||
type ClipboardEventHandler,
|
||||
type ComponentProps,
|
||||
type CompositionEventHandler,
|
||||
createContext,
|
||||
type FormEvent,
|
||||
type FormEventHandler,
|
||||
@@ -145,6 +151,7 @@ const usePromptInputValidation = () => useContext(PromptInputValidationContext);
|
||||
|
||||
export type PromptInputProviderProps = PropsWithChildren<{
|
||||
initialInput?: string;
|
||||
uploadPolicy?: UploadPolicy;
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -153,8 +160,12 @@ export type PromptInputProviderProps = PropsWithChildren<{
|
||||
*/
|
||||
export function PromptInputProvider({
|
||||
initialInput: initialTextInput = "",
|
||||
uploadPolicy,
|
||||
children,
|
||||
}: PromptInputProviderProps) {
|
||||
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
||||
const effectiveUploadPolicy =
|
||||
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
||||
// ----- textInput state
|
||||
const [textInput, setTextInput] = useState(initialTextInput);
|
||||
const clearInput = useCallback(() => setTextInput(""), []);
|
||||
@@ -163,6 +174,7 @@ export function PromptInputProvider({
|
||||
const [attachmentFiles, setAttachmentFiles] = useState<
|
||||
(PromptInputFilePart & { id: string })[]
|
||||
>([]);
|
||||
const attachmentsRef = useRef(attachmentFiles);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const openRef = useRef<() => void>(() => {});
|
||||
|
||||
@@ -172,9 +184,26 @@ export function PromptInputProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
setAttachmentFiles((prev) =>
|
||||
prev.concat(
|
||||
incoming.map((file) => ({
|
||||
const current = attachmentsRef.current;
|
||||
const existing = current.map((item) => ({
|
||||
name: item.filename ?? "attachment",
|
||||
size: item.file?.size ?? 0,
|
||||
type: item.mediaType ?? "",
|
||||
}));
|
||||
const validation = validateChatUploadFiles(
|
||||
incoming,
|
||||
existing,
|
||||
effectiveUploadPolicy,
|
||||
);
|
||||
for (const message of new Set(
|
||||
validation.issues.map((issue) => issue.message),
|
||||
)) {
|
||||
toast.error(message);
|
||||
}
|
||||
if (validation.accepted.length === 0) return;
|
||||
|
||||
const next = current.concat(
|
||||
validation.accepted.map((file) => ({
|
||||
id: nanoid(),
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
@@ -182,33 +211,31 @@ export function PromptInputProvider({
|
||||
filename: file.name,
|
||||
file,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentFiles(next);
|
||||
}, [effectiveUploadPolicy]);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setAttachmentFiles((prev) => {
|
||||
const found = prev.find((f) => f.id === id);
|
||||
if (found?.url) {
|
||||
URL.revokeObjectURL(found.url);
|
||||
}
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
const current = attachmentsRef.current;
|
||||
const found = current.find((f) => f.id === id);
|
||||
if (found?.url) URL.revokeObjectURL(found.url);
|
||||
const next = current.filter((f) => f.id !== id);
|
||||
attachmentsRef.current = next;
|
||||
setAttachmentFiles(next);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setAttachmentFiles((prev) => {
|
||||
for (const f of prev) {
|
||||
if (f.url) {
|
||||
URL.revokeObjectURL(f.url);
|
||||
}
|
||||
for (const f of attachmentsRef.current) {
|
||||
if (f.url) {
|
||||
URL.revokeObjectURL(f.url);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
attachmentsRef.current = [];
|
||||
setAttachmentFiles([]);
|
||||
}, []);
|
||||
|
||||
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
|
||||
const attachmentsRef = useRef(attachmentFiles);
|
||||
attachmentsRef.current = attachmentFiles;
|
||||
|
||||
// Cleanup blob URLs on unmount to prevent memory leaks
|
||||
@@ -459,8 +486,15 @@ export type PromptInputProps = Omit<
|
||||
// Minimal constraints
|
||||
maxFiles?: number;
|
||||
maxFileSize?: number; // bytes
|
||||
uploadPolicy?: UploadPolicy;
|
||||
onError?: (err: {
|
||||
code: "max_files" | "max_file_size" | "accept" | "unsupported_package";
|
||||
code:
|
||||
| "max_files"
|
||||
| "max_file_size"
|
||||
| "max_total_size"
|
||||
| "unsupported_type"
|
||||
| "accept"
|
||||
| "unsupported_package";
|
||||
message: string;
|
||||
}) => void;
|
||||
onSubmit: (
|
||||
@@ -478,11 +512,22 @@ export const PromptInput = ({
|
||||
syncHiddenInput,
|
||||
maxFiles,
|
||||
maxFileSize,
|
||||
uploadPolicy,
|
||||
onError,
|
||||
onSubmit,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputProps) => {
|
||||
const { data: fetchedUploadPolicy } = useUploadPolicy();
|
||||
const effectiveUploadPolicy =
|
||||
uploadPolicy ?? fetchedUploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
||||
const policyAccept = useMemo(
|
||||
() =>
|
||||
Object.values(effectiveUploadPolicy.groups)
|
||||
.flatMap((group) => group.extensions)
|
||||
.join(","),
|
||||
[effectiveUploadPolicy],
|
||||
);
|
||||
// Try to use a provider controller if present
|
||||
const controller = useOptionalPromptInputController();
|
||||
const usingProvider = !!controller;
|
||||
@@ -517,6 +562,9 @@ export const PromptInput = ({
|
||||
.filter(Boolean);
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.startsWith(".")) {
|
||||
return f.name.toLowerCase().endsWith(pattern.toLowerCase());
|
||||
}
|
||||
if (pattern.endsWith("/*")) {
|
||||
const prefix = pattern.slice(0, -1); // e.g: image/* -> image/
|
||||
return f.type.startsWith(prefix);
|
||||
@@ -531,52 +579,67 @@ export const PromptInput = ({
|
||||
(fileList: File[] | FileList) => {
|
||||
const incoming = Array.from(fileList);
|
||||
const accepted = incoming.filter((f) => matchesAccept(f));
|
||||
if (incoming.length && accepted.length === 0) {
|
||||
onError?.({
|
||||
if (accepted.length < incoming.length) {
|
||||
const error = {
|
||||
code: "accept",
|
||||
message: "No files match the accepted types.",
|
||||
});
|
||||
return;
|
||||
message: "部分文件不符合当前输入框允许的格式。",
|
||||
} as const;
|
||||
onError?.(error);
|
||||
if (!onError) toast.error(error.message);
|
||||
}
|
||||
const withinSize = (f: File) =>
|
||||
maxFileSize ? f.size <= maxFileSize : true;
|
||||
const sized = accepted.filter(withinSize);
|
||||
if (accepted.length > 0 && sized.length === 0) {
|
||||
onError?.({
|
||||
code: "max_file_size",
|
||||
message: "All files exceed the maximum size.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setItems((prev) => {
|
||||
const capacity =
|
||||
const localPolicy: UploadPolicy = {
|
||||
...effectiveUploadPolicy,
|
||||
max_files_per_message:
|
||||
typeof maxFiles === "number"
|
||||
? Math.max(0, maxFiles - prev.length)
|
||||
: undefined;
|
||||
const capped =
|
||||
typeof capacity === "number" ? sized.slice(0, capacity) : sized;
|
||||
if (typeof capacity === "number" && sized.length > capacity) {
|
||||
onError?.({
|
||||
code: "max_files",
|
||||
message: "Too many files. Some were not added.",
|
||||
});
|
||||
}
|
||||
const next: (PromptInputFilePart & { id: string })[] = [];
|
||||
for (const file of capped) {
|
||||
next.push({
|
||||
? Math.min(effectiveUploadPolicy.max_files_per_message, maxFiles)
|
||||
: effectiveUploadPolicy.max_files_per_message,
|
||||
groups: Object.fromEntries(
|
||||
Object.entries(effectiveUploadPolicy.groups).map(([key, group]) => [
|
||||
key,
|
||||
{
|
||||
...group,
|
||||
max_bytes:
|
||||
typeof maxFileSize === "number"
|
||||
? Math.min(group.max_bytes, maxFileSize)
|
||||
: group.max_bytes,
|
||||
},
|
||||
]),
|
||||
) as UploadPolicy["groups"],
|
||||
};
|
||||
const existing = items.map((item) => ({
|
||||
name: item.filename ?? "attachment",
|
||||
size: item.file?.size ?? 0,
|
||||
type: item.mediaType ?? "",
|
||||
}));
|
||||
const validation = validateChatUploadFiles(
|
||||
accepted,
|
||||
existing,
|
||||
localPolicy,
|
||||
);
|
||||
for (const issue of Array.from(
|
||||
new Map(
|
||||
validation.issues.map((item) => [item.message, item]),
|
||||
).values(),
|
||||
)) {
|
||||
onError?.({ code: issue.code, message: issue.message });
|
||||
if (!onError) toast.error(issue.message);
|
||||
}
|
||||
if (validation.accepted.length === 0) return;
|
||||
|
||||
setItems((prev) =>
|
||||
prev.concat(
|
||||
validation.accepted.map((file) => ({
|
||||
id: nanoid(),
|
||||
type: "file",
|
||||
type: "file" as const,
|
||||
url: URL.createObjectURL(file),
|
||||
mediaType: file.type,
|
||||
filename: file.name,
|
||||
file,
|
||||
});
|
||||
}
|
||||
return prev.concat(next);
|
||||
});
|
||||
})),
|
||||
),
|
||||
);
|
||||
},
|
||||
[matchesAccept, maxFiles, maxFileSize, onError],
|
||||
[effectiveUploadPolicy, items, matchesAccept, maxFiles, maxFileSize, onError],
|
||||
);
|
||||
|
||||
const removeLocal = useCallback(
|
||||
@@ -765,11 +828,17 @@ export const PromptInput = ({
|
||||
event.preventDefault();
|
||||
|
||||
const form = event.currentTarget;
|
||||
// Prefer the live textarea. Firefox 102 applies the chosen candidate to
|
||||
// the DOM before React state catches up, and a send click in that gap
|
||||
// would otherwise submit the previous pinyin-less value.
|
||||
const liveText = form.querySelector<HTMLTextAreaElement>(
|
||||
"textarea[name='message']",
|
||||
)?.value;
|
||||
const text = usingProvider
|
||||
? controller.textInput.value
|
||||
? (liveText ?? controller.textInput.value)
|
||||
: (() => {
|
||||
const formData = new FormData(form);
|
||||
return (formData.get("message") as string) || "";
|
||||
return (formData.get("message") as string) || liveText || "";
|
||||
})();
|
||||
|
||||
// Reset form immediately after capturing text to avoid race condition
|
||||
@@ -832,7 +901,7 @@ export const PromptInput = ({
|
||||
const inner = (
|
||||
<PromptInputValidationContext.Provider value={sanitizeIncomingFiles}>
|
||||
<input
|
||||
accept={accept}
|
||||
accept={accept ?? policyAccept}
|
||||
aria-label="Upload files"
|
||||
className="hidden"
|
||||
multiple={multiple}
|
||||
@@ -847,10 +916,12 @@ export const PromptInput = ({
|
||||
ref={formRef}
|
||||
{...props}
|
||||
>
|
||||
{/* Do not rely on InputGroup's :has(textarea) selector here: Chrome
|
||||
versions before 105 ignore :has(), retain h-9, and clip the whole
|
||||
multi-line composer to a single row. */}
|
||||
<InputGroup className="h-auto min-h-16">{children}</InputGroup>
|
||||
{/* Do not rely on InputGroup's :has() selectors here: Chrome versions
|
||||
before 105 ignore them, keep the horizontal flex layout, and
|
||||
squeeze the textarea beside the footer controls. */}
|
||||
<InputGroup className="h-auto min-h-16 flex-col items-stretch">
|
||||
{children}
|
||||
</InputGroup>
|
||||
</form>
|
||||
</PromptInputValidationContext.Provider>
|
||||
);
|
||||
@@ -880,17 +951,43 @@ export type PromptInputTextareaProps = ComponentProps<
|
||||
export const PromptInputTextarea = ({
|
||||
onChange,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
className,
|
||||
placeholder = "What would you like to know?",
|
||||
defaultValue,
|
||||
...props
|
||||
}: PromptInputTextareaProps) => {
|
||||
const controller = useOptionalPromptInputController();
|
||||
const attachments = usePromptInputAttachments();
|
||||
const sanitizeIncomingFiles = usePromptInputValidation();
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const composingRef = useRef(false);
|
||||
const compositionEndedAtRef = useRef(0);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onInputRef = useRef(onInput);
|
||||
const onKeyDownRef = useRef(onKeyDown);
|
||||
const onCompositionStartRef = useRef(onCompositionStart);
|
||||
const onCompositionEndRef = useRef(onCompositionEnd);
|
||||
const setInputRef = useRef(controller?.textInput.setInput);
|
||||
const attachmentsRef = useRef(attachments);
|
||||
onChangeRef.current = onChange;
|
||||
onInputRef.current = onInput;
|
||||
onKeyDownRef.current = onKeyDown;
|
||||
onCompositionStartRef.current = onCompositionStart;
|
||||
onCompositionEndRef.current = onCompositionEnd;
|
||||
setInputRef.current = controller?.textInput.setInput;
|
||||
attachmentsRef.current = attachments;
|
||||
|
||||
const publishValue = useCallback((value: string) => {
|
||||
setInputRef.current?.(value);
|
||||
}, []);
|
||||
|
||||
const resizeForLegacyChrome = useCallback((textarea: HTMLTextAreaElement) => {
|
||||
// Firefox 102 commits an open IME composition as raw pinyin when the
|
||||
// textarea's height or overflow changes mid-composition.
|
||||
if (composingRef.current) return;
|
||||
if (
|
||||
typeof CSS !== "undefined" &&
|
||||
typeof CSS.supports === "function" &&
|
||||
@@ -911,6 +1008,7 @@ export const PromptInputTextarea = ({
|
||||
// onInput does not run for a controlled value filled by a suggested question
|
||||
// or restored draft. Resize once after every render in legacy Chrome too.
|
||||
useEffect(() => {
|
||||
if (composingRef.current) return;
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
@@ -922,13 +1020,28 @@ export const PromptInputTextarea = ({
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (isIMEComposing(e, isComposing)) {
|
||||
const action = imeEnterAction({
|
||||
isComposing: composingRef.current,
|
||||
nativeIsComposing: e.nativeEvent.isComposing || e.key === "Process",
|
||||
keyCode: e.keyCode,
|
||||
compositionEndedAt: compositionEndedAtRef.current,
|
||||
now: performance.now(),
|
||||
});
|
||||
// Firefox 102 confirms the candidate on this key. preventDefault here
|
||||
// cancels that confirmation and leaves the pinyin in the box.
|
||||
if (action === "ignore") {
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
onKeyDownRef.current?.(e);
|
||||
if (e.defaultPrevented || e.shiftKey) {
|
||||
return;
|
||||
}
|
||||
// The confirming Enter already finished the candidate. Swallow it so
|
||||
// Firefox does not also insert a newline or send the message.
|
||||
e.preventDefault();
|
||||
if (action === "swallow") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the submit button is disabled before submitting
|
||||
const form = e.currentTarget.form;
|
||||
@@ -940,22 +1053,59 @@ export const PromptInputTextarea = ({
|
||||
}
|
||||
|
||||
form?.requestSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
onKeyDownRef.current?.(e);
|
||||
if (e.defaultPrevented) return;
|
||||
|
||||
if (composingRef.current) return;
|
||||
|
||||
// Remove last attachment when Backspace is pressed and textarea is empty
|
||||
if (
|
||||
e.key === "Backspace" &&
|
||||
e.currentTarget.value === "" &&
|
||||
attachments.files.length > 0
|
||||
attachmentsRef.current.files.length > 0
|
||||
) {
|
||||
e.preventDefault();
|
||||
const lastAttachment = attachments.files.at(-1);
|
||||
const lastAttachment = attachmentsRef.current.files.at(-1);
|
||||
if (lastAttachment) {
|
||||
attachments.remove(lastAttachment.id);
|
||||
attachmentsRef.current.remove(lastAttachment.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompositionStart: CompositionEventHandler<HTMLTextAreaElement> = (
|
||||
event,
|
||||
) => {
|
||||
composingRef.current = true;
|
||||
compositionEndedAtRef.current = 0;
|
||||
onCompositionStartRef.current?.(event);
|
||||
};
|
||||
|
||||
const handleCompositionEnd: CompositionEventHandler<HTMLTextAreaElement> = (
|
||||
event,
|
||||
) => {
|
||||
onCompositionEndRef.current?.(event);
|
||||
composingRef.current = false;
|
||||
compositionEndedAtRef.current = performance.now();
|
||||
const el = event.currentTarget;
|
||||
// Firefox 102 dispatches compositionend before the committed candidate is
|
||||
// visible, and before the input event that carries it. Syncing the
|
||||
// compositionend value here would write the raw pinyin back over 汉字.
|
||||
window.setTimeout(() => {
|
||||
if (el.isConnected) publishValue(el.value);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = (event) => {
|
||||
// Keep the controlled value identical to the DOM, including the pinyin
|
||||
// still being composed. Otherwise React restores the previous value at
|
||||
// the end of the input event and Firefox 102 cannot replace it.
|
||||
publishValue(event.currentTarget.value);
|
||||
onChangeRef.current?.(event);
|
||||
};
|
||||
|
||||
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = (event) => {
|
||||
const items = event.clipboardData?.items;
|
||||
|
||||
@@ -985,35 +1135,28 @@ export const PromptInputTextarea = ({
|
||||
}
|
||||
};
|
||||
|
||||
const controlledProps = controller
|
||||
? {
|
||||
value: controller.textInput.value,
|
||||
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
controller.textInput.setInput(e.currentTarget.value);
|
||||
onChange?.(e);
|
||||
},
|
||||
}
|
||||
: {
|
||||
onChange,
|
||||
};
|
||||
|
||||
return (
|
||||
<InputGroupTextarea
|
||||
{...props}
|
||||
className={cn("field-sizing-content max-h-48 min-h-16", className)}
|
||||
name="message"
|
||||
ref={textareaRef}
|
||||
rows={3}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onChange={handleChange}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={(event) => {
|
||||
resizeForLegacyChrome(event.currentTarget);
|
||||
onInput?.(event);
|
||||
if (!composingRef.current) {
|
||||
resizeForLegacyChrome(event.currentTarget);
|
||||
}
|
||||
onInputRef.current?.(event);
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
{...controlledProps}
|
||||
{...(controller
|
||||
? { value: controller.textInput.value }
|
||||
: { defaultValue })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1133,18 +1276,22 @@ export const PromptInputSubmit = ({
|
||||
...props
|
||||
}: PromptInputSubmitProps) => {
|
||||
let Icon = <ArrowUpIcon className="size-4" />;
|
||||
let ariaLabel = "发送消息";
|
||||
|
||||
if (status === "submitted") {
|
||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||
ariaLabel = "正在发送";
|
||||
} else if (status === "streaming") {
|
||||
Icon = <SquareIcon className="size-4" />;
|
||||
ariaLabel = "停止生成";
|
||||
} else if (status === "error") {
|
||||
Icon = <XIcon className="size-4" />;
|
||||
ariaLabel = "重新发送";
|
||||
}
|
||||
|
||||
return (
|
||||
<InputGroupButton
|
||||
aria-label="Submit"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
status === "streaming" &&
|
||||
|
||||
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { SafeStreamdown } from "@/core/streamdown";
|
||||
import { reasoningPlugins } from "@/core/streamdown/plugins";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
@@ -179,7 +179,7 @@ export const ReasoningContent = memo(
|
||||
{...props}
|
||||
>
|
||||
{typeof children === "string" ? (
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
<SafeStreamdown {...reasoningPlugins}>{children}</SafeStreamdown>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,11 @@ import { queryClient } from "@/components/query-client-provider";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import Empty from "./empty";
|
||||
import { NetworkActor } from "./network-actor";
|
||||
import {
|
||||
normalizeV2UploadSources,
|
||||
type StudioUploadSource,
|
||||
type V2UploadResponse,
|
||||
} from "@/core/studio/document-upload";
|
||||
|
||||
// ── 适配层 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -86,22 +91,7 @@ interface DocumentUploadDialogContextType {
|
||||
data?: { query: string; mode: string }
|
||||
) => Promise<void>;
|
||||
// 上传成功回调,返回上传的来源信息
|
||||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||||
}
|
||||
|
||||
// ── 任务轮询类型 ─────────────────────────────────────────────
|
||||
interface TaskSource {
|
||||
task_id: string;
|
||||
title: string;
|
||||
status: 'processing' | 'success' | 'error';
|
||||
source_type: string;
|
||||
}
|
||||
|
||||
interface UploadResponse {
|
||||
status: string;
|
||||
uploaded: number;
|
||||
skipped: number;
|
||||
sources: TaskSource[];
|
||||
onUploadComplete?: (sources: StudioUploadSource[]) => void;
|
||||
}
|
||||
|
||||
interface listDataItem {
|
||||
@@ -224,7 +214,7 @@ export const DocumentUploadDialogProvider: FC<{
|
||||
) => void | Promise<void>;
|
||||
searchSpace: { id: number | string;[key: string]: unknown };
|
||||
onUploadSuccess?: () => void;
|
||||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||||
onUploadComplete?: (sources: StudioUploadSource[]) => void;
|
||||
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialSearchText, setInitialSearchText] = useState("");
|
||||
@@ -353,27 +343,23 @@ const DocumentUploadPopupContent: FC<{
|
||||
for (const file of files) {
|
||||
formData.append("files", file);
|
||||
}
|
||||
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
|
||||
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload-v2`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as UploadResponse;
|
||||
const data = await res.json() as V2UploadResponse;
|
||||
// 调用上传完成回调,传递来源信息
|
||||
if (data.sources && data.sources.length > 0) {
|
||||
onUploadComplete?.(data.sources.map(s => ({
|
||||
id: undefined, // 后端返回的数据中暂时没有 id
|
||||
title: s.title,
|
||||
task_id: s.task_id,
|
||||
status: s.status,
|
||||
})));
|
||||
onUploadComplete?.(normalizeV2UploadSources(data));
|
||||
}
|
||||
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
|
||||
// 只关闭上传弹窗
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error("上传失败,请重试");
|
||||
const payload = await res.json().catch(() => null) as { detail?: string } | null;
|
||||
toast.error(payload?.detail || `上传失败(HTTP ${res.status})`);
|
||||
}
|
||||
} catch {
|
||||
toast.error("上传失败,请重试");
|
||||
@@ -410,7 +396,6 @@ const DocumentUploadPopupContent: FC<{
|
||||
selectedSearchSources.map((s) => ({
|
||||
id: undefined,
|
||||
title: s.title,
|
||||
task_id: undefined,
|
||||
status: "success" as const,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLayoutEffect } from "react";
|
||||
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
applyFontScaleToDocument,
|
||||
normalizeFontScale,
|
||||
normalizeFontScaleMode,
|
||||
} from "@/core/ui/page-zoom";
|
||||
|
||||
/**
|
||||
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
|
||||
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
|
||||
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
|
||||
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
|
||||
* 「仅文字」把倍数写到 `--ui-font-scale`,只放大 Tailwind 字号 token 和正文继承字号。
|
||||
* 「整页缩放」改在 `<html>` 上设置 CSS zoom,写死的 px 字号、间距和图标一起变;
|
||||
* 此时 `--ui-font-scale` 固定为 1,避免和 zoom 叠乘。
|
||||
* `fontScale === 1` 即默认 16px。
|
||||
*/
|
||||
export const BASE_FONT_PX = 16;
|
||||
@@ -21,25 +26,36 @@ export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[]
|
||||
{ value: 1.25, label: "特大", px: 20 },
|
||||
];
|
||||
|
||||
export const FONT_SCALE_MODE_OPTIONS: {
|
||||
value: "text" | "page";
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "text",
|
||||
label: "仅文字",
|
||||
description: "只放大文字,间距和布局保持原样。",
|
||||
},
|
||||
{
|
||||
value: "page",
|
||||
label: "整页缩放",
|
||||
description: "文字、间距、图标和写死的像素一起放大。",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
|
||||
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
|
||||
* 不渲染任何 DOM。
|
||||
* 把字号设置写到文档根节点。挂在应用根部,刷新后、绘制前生效。不渲染任何 DOM。
|
||||
*/
|
||||
export function FontScaleApplier() {
|
||||
const [settings] = useLocalSettings();
|
||||
const { fontScale } = settings.display;
|
||||
const { fontScale, fontScaleMode } = settings.display;
|
||||
|
||||
useEffect(() => {
|
||||
const scale =
|
||||
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
|
||||
? fontScale
|
||||
: 1;
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--ui-font-scale", String(scale));
|
||||
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
|
||||
root.style.removeProperty("font-size");
|
||||
}, [fontScale]);
|
||||
useLayoutEffect(() => {
|
||||
applyFontScaleToDocument(
|
||||
normalizeFontScaleMode(fontScaleMode),
|
||||
normalizeFontScale(fontScale),
|
||||
);
|
||||
}, [fontScale, fontScaleMode]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
||||
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
|
||||
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
|
||||
* a real document URL, so fragment links scroll within the frame as expected —
|
||||
* all without enabling scripts (the sandbox stays script-free for security; the
|
||||
* server already sanitizes script vectors and this is defense-in-depth).
|
||||
* The sandbox permits scripts so the preview can use the app-bundled HTML
|
||||
* visualization runtimes, while still withholding same-origin privileges.
|
||||
*
|
||||
* The `charset=utf-8` on the blob type is required: a blob: URL whose
|
||||
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
|
||||
@@ -26,7 +26,9 @@ export function HtmlPreviewFrame({
|
||||
const [url, setUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(html ?? ""),
|
||||
], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { toCssPx } from "@/core/ui/page-zoom";
|
||||
|
||||
import type { TooltipState } from "./graphTypes";
|
||||
import { getNodeTitle, NODE_KIND_META } from "./graphUtils";
|
||||
|
||||
@@ -9,8 +11,8 @@ export function GraphTooltip({ tooltip }: GraphTooltipProps) {
|
||||
if (!tooltip) return null;
|
||||
const meta = NODE_KIND_META[tooltip.kind];
|
||||
// Keep the tooltip inside the viewport.
|
||||
const left = Math.min(tooltip.x + 14, window.innerWidth - 336);
|
||||
const top = Math.min(tooltip.y + 14, window.innerHeight - 120);
|
||||
const left = Math.min(toCssPx(tooltip.x) + 14, window.innerWidth - 336);
|
||||
const top = Math.min(toCssPx(tooltip.y) + 14, window.innerHeight - 120);
|
||||
|
||||
return (
|
||||
<div className="kg-tooltip" style={{ left, top }}>
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
top: 4rem;
|
||||
z-index: 40;
|
||||
width: 98px;
|
||||
height: calc(100vh - 4rem);
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* `?hideHeader=1`: keep the menu rail, but let it occupy the Header's space. */
|
||||
.sidebar-contnet--header-hidden {
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
height: calc(100vh / var(--df-page-zoom, 1));
|
||||
}
|
||||
|
||||
.sidebar-contnet--header-hidden .item-nav-content {
|
||||
height: calc(100vh - 20px);
|
||||
min-height: calc(100vh - 20px);
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 20px);
|
||||
min-height: calc(100vh / var(--df-page-zoom, 1) - 20px);
|
||||
}
|
||||
|
||||
.sidebar-contents {
|
||||
@@ -160,8 +160,8 @@
|
||||
|
||||
.item-nav-content {
|
||||
width: 155px;
|
||||
height: calc(100vh - 4rem - 20px);
|
||||
min-height: calc(100vh - 4rem - 20px);
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
|
||||
min-height: calc(100vh / var(--df-page-zoom, 1) - 4rem - 20px);
|
||||
background: rgba(3, 38, 80, 0.8);
|
||||
border: 1px solid;
|
||||
border-color: rgba(43, 143, 254, 0.6);
|
||||
@@ -634,7 +634,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
|
||||
top: 4rem;
|
||||
z-index: 40;
|
||||
width: 96px;
|
||||
height: calc(100vh - 4rem);
|
||||
height: calc(100vh / var(--df-page-zoom, 1) - 4rem);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: #ffffff;
|
||||
@@ -653,7 +653,7 @@ html[data-custom-theme="true"] .light-topnav-item--active::after {
|
||||
/* `?hideHeader=1`: preserve the light sidebar while removing only Header. */
|
||||
.light-sidebar--header-hidden {
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
height: calc(100vh / var(--df-page-zoom, 1));
|
||||
}
|
||||
|
||||
/* 二级列:固定 96px,竖排图标卡片 + 底部设置 */
|
||||
|
||||
@@ -194,7 +194,7 @@ export function PageSidebar({
|
||||
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 ${
|
||||
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh/var(--df-page-zoom,1)-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"
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
ScrollText,
|
||||
Timer,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
@@ -222,14 +224,12 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
<User className="size-4" />
|
||||
个人信息
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
||||
>
|
||||
<ListTree className="size-4" />
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
||||
>
|
||||
<ListTree className="size-4" />
|
||||
{isAdmin ? "默认菜单管理" : "我的菜单"}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/task-buttons")}
|
||||
@@ -270,6 +270,22 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skill-timing")}
|
||||
>
|
||||
<Timer className="size-4" />
|
||||
技能耗时排查
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
|
||||
>
|
||||
<ScrollText className="size-4" />
|
||||
模型收发记录
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||
<Cpu className="size-4" />
|
||||
@@ -484,7 +500,9 @@ function LegacyAccordionSidebar({
|
||||
/>
|
||||
<aside
|
||||
className={`fixed left-0 z-40 flex flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
|
||||
headerHidden ? "top-0 h-screen" : "top-16 h-[calc(100vh-4rem)]"
|
||||
headerHidden
|
||||
? "top-0 h-[calc(100vh/var(--df-page-zoom,1))]"
|
||||
: "top-16 h-[calc(100vh/var(--df-page-zoom,1)-4rem)]"
|
||||
} ${
|
||||
isDark
|
||||
? "border-r border-blue-800/30 bg-slate-900"
|
||||
@@ -561,12 +579,10 @@ function LegacyAccordionSidebar({
|
||||
<User className="size-4" />
|
||||
个人信息
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
|
||||
<ListTree className="size-4" />
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/menu-management")}>
|
||||
<ListTree className="size-4" />
|
||||
{isAdmin ? "默认菜单管理" : "我的菜单"}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/task-buttons")}>
|
||||
<MousePointerClick className="size-4" />
|
||||
@@ -591,6 +607,22 @@ function LegacyAccordionSidebar({
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skill-timing")}
|
||||
>
|
||||
<Timer className="size-4" />
|
||||
技能耗时排查
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
|
||||
>
|
||||
<ScrollText className="size-4" />
|
||||
模型收发记录
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => openSettings("encoder")}>
|
||||
<Cpu className="size-4" />
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { ReactUiErrorBoundary } from "./react-ui-error-boundary";
|
||||
|
||||
// Offline browser QA: real React production render/commit recursion, not a
|
||||
// fabricated #185 thrown by a unit-test stub. A manual button click reloads
|
||||
// this same page without submitting any conversation or model request.
|
||||
const key = "deerflow.boundary-browser-check";
|
||||
let mounts = 0;
|
||||
function InfiniteCommitLoop() {
|
||||
const [, update] = useState(0);
|
||||
useState(() => { mounts += 1; return 0; });
|
||||
useLayoutEffect(() => { update((value) => value + 1); });
|
||||
return <div>Loop fixture</div>;
|
||||
}
|
||||
|
||||
function result(passed: boolean, details: Record<string, unknown>) {
|
||||
const output = document.createElement("pre");
|
||||
output.id = "boundary-check-result";
|
||||
output.textContent = JSON.stringify({ passed, ...details });
|
||||
document.body.append(output);
|
||||
}
|
||||
|
||||
const resumed = sessionStorage.getItem(key) === "manual-reload";
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<ReactUiErrorBoundary>
|
||||
{resumed ? <div id="healthy">Existing page restored</div> : <InfiniteCommitLoop />}
|
||||
</ReactUiErrorBoundary>,
|
||||
);
|
||||
|
||||
if (resumed) {
|
||||
sessionStorage.removeItem(key);
|
||||
window.setTimeout(() => result(Boolean(document.getElementById("healthy")), {
|
||||
phase: "manual-reload-restored", renderLoopCaught: true, noAutomaticRemount: true,
|
||||
}), 100);
|
||||
} else {
|
||||
window.setTimeout(() => {
|
||||
const alert = document.querySelector('[role="alert"]');
|
||||
const before = mounts;
|
||||
if (!alert || /#185|react\.dev|Maximum update depth/.test(alert.textContent ?? "")) {
|
||||
result(false, { phase: "friendly-fallback", hasAlert: Boolean(alert) });
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (mounts !== before || before !== 1) {
|
||||
result(false, { phase: "unexpected-automatic-remount", mounts, before });
|
||||
return;
|
||||
}
|
||||
sessionStorage.setItem(key, "manual-reload");
|
||||
(alert.querySelector("button") as HTMLButtonElement).click();
|
||||
}, 250);
|
||||
}, 100);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
import { isReactMaximumUpdateDepthError } from "@/core/threads/stream-error-classification";
|
||||
import { REACT_UI_ERROR_MESSAGE } from "@/core/threads/react-ui-error";
|
||||
|
||||
/** Last-resort render/commit guard. It deliberately never auto-remounts a broken
|
||||
* tree, which could otherwise repeatedly resume the same run and crash again. */
|
||||
export class ReactUiErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
if (!isReactMaximumUpdateDepthError(error)) throw error;
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(_error: unknown, info: ErrorInfo) {
|
||||
console.warn("[deer-flow] react_ui_render_error", {
|
||||
errorType: "react_maximum_update_depth",
|
||||
componentStack: info.componentStack,
|
||||
recovery: "manual_reload",
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.failed) return this.props.children;
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-6">
|
||||
<section role="alert" className="max-w-lg rounded-lg border p-6 text-center">
|
||||
<h1 className="mb-3 text-lg font-medium">页面暂时无法显示</h1>
|
||||
<p className="mb-5 text-sm text-muted-foreground">{REACT_UI_ERROR_MESSAGE}</p>
|
||||
<button
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground"
|
||||
onClick={() => window.location.reload()}
|
||||
type="button"
|
||||
>
|
||||
重新载入页面
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Lightweight, dependency-free fallback used while a route chunk loads. */
|
||||
export function RouteLoadingFallback() {
|
||||
return (
|
||||
<div
|
||||
className="text-muted-foreground flex size-full min-h-32 items-center justify-center gap-2 bg-background"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span
|
||||
className="border-muted-foreground/30 border-t-foreground size-4 animate-spin rounded-full border-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm">正在加载页面…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,7 +61,7 @@ const DialogOverlay = React.forwardRef<
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"legacy-dialog-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[200] bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -82,26 +82,31 @@ const DialogContent = React.forwardRef<
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay className={overlayClassName} />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
<div
|
||||
data-slot="dialog-viewport"
|
||||
className={cn("legacy-dialog-viewport", overlayClassName)}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 relative z-[200] grid min-h-0 max-h-full w-full max-w-[calc(100%-2rem)] pointer-events-auto gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -240,7 +240,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -62,7 +62,7 @@ const SelectContent = React.forwardRef<
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
|
||||
"legacy-floating-layer bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -21,12 +20,12 @@ export function Breadcrumb({
|
||||
)}
|
||||
>
|
||||
{segments.map((segment, i) => (
|
||||
<Fragment key={`${segment}-${i}`}>
|
||||
<div className="contents" key={`${segment}-${i}`}>
|
||||
<span className="hidden shrink-0 whitespace-nowrap sm:inline">
|
||||
{segment}
|
||||
</span>
|
||||
<ChevronRight className="hidden size-3 shrink-0 text-slate-300 sm:inline dark:text-slate-600" />
|
||||
</Fragment>
|
||||
</div>
|
||||
))}
|
||||
<span
|
||||
className="truncate text-[13px] font-medium text-slate-600 dark:text-slate-300"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -17,6 +19,11 @@ export function UserManagementPage() {
|
||||
title={t.adminUsers.title}
|
||||
description={t.adminUsers.description}
|
||||
>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/page/strategy/admin/login-whitelist">管理密码与登录口令</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -534,11 +534,6 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
}
|
||||
}
|
||||
|
||||
const enabledSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.enabled),
|
||||
[skills],
|
||||
);
|
||||
|
||||
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||
// both the card header and the compact list row.
|
||||
const statusBadges = (
|
||||
@@ -966,7 +961,7 @@ export function AgentCard({ agent, layout = "card", onPublisherClick }: AgentCar
|
||||
</span>
|
||||
</div>
|
||||
<SkillTransferSelector
|
||||
skills={enabledSkills}
|
||||
skills={skills}
|
||||
selected={selectedSkills}
|
||||
onChange={setSelectedSkills}
|
||||
isLoading={isSkillsLoading}
|
||||
|
||||
@@ -51,6 +51,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useAgentsPaginated } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
@@ -69,6 +73,8 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
||||
import {
|
||||
bootstrapResumePath,
|
||||
isBootstrapThread,
|
||||
@@ -379,6 +385,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
() => sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||
[threads],
|
||||
);
|
||||
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -479,6 +486,10 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectInfo = myProjectConversationInfoOfThread(
|
||||
thread,
|
||||
projectThreadNames,
|
||||
);
|
||||
const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread);
|
||||
const isActive = href === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
@@ -492,6 +503,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
href={href}
|
||||
className={cn(
|
||||
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||
projectInfo && "h-auto min-h-12 py-1.5",
|
||||
isActive
|
||||
? "border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
@@ -505,6 +517,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectInfo ? (
|
||||
<MyProjectThreadIcon type={projectInfo.type} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -516,8 +530,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) {
|
||||
{pinned && (
|
||||
<Pin className="text-muted-foreground/70 size-3 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
projectInfo && "flex flex-col items-start gap-0.5",
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectInfo && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4 max-w-[calc(100%_-_1rem)]"
|
||||
projectName={projectInfo.projectName}
|
||||
type={projectInfo.type}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
|
||||
@@ -152,6 +152,8 @@ export function AgentDetailPanel({
|
||||
positionName,
|
||||
skillsIcon,
|
||||
skillsTitle,
|
||||
belowSkillsSlot,
|
||||
feedbackScoreSlot,
|
||||
showCommonQuestions = false,
|
||||
jobResponsibilities,
|
||||
panelTitle,
|
||||
@@ -181,6 +183,10 @@ export function AgentDetailPanel({
|
||||
positionName?: string;
|
||||
skillsIcon?: ReactNode;
|
||||
skillsTitle?: string;
|
||||
/** 渲染在技能列表区块下方的自定义内容(8BF 用它放「产物提交 position」配置)。 */
|
||||
belowSkillsSlot?: ReactNode;
|
||||
/** Optional answer-feedback summary rendered near the agent identity. */
|
||||
feedbackScoreSlot?: ReactNode;
|
||||
showCommonQuestions?: boolean;
|
||||
/**
|
||||
* 业务链席位上配置的岗位职责。传入(含空字符串)时在常用问题上方展示;
|
||||
@@ -744,6 +750,8 @@ export function AgentDetailPanel({
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!editing && feedbackScoreSlot}
|
||||
|
||||
|
||||
{/* ── 岗位职责(业务链席位配置,只读)────────────────────────────── */}
|
||||
{jobResponsibilities !== undefined && (
|
||||
@@ -905,7 +913,7 @@ export function AgentDetailPanel({
|
||||
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
||||
<Section
|
||||
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
|
||||
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
|
||||
title={skillsTitle ?? `已配置技能 ${skillNamesShown.length} 个`}
|
||||
className="position-roundtable-skills-section"
|
||||
extra={
|
||||
editing ? (
|
||||
@@ -940,6 +948,9 @@ export function AgentDetailPanel({
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 技能列表下方插槽(8BF 产物提交 position 等)────────────────── */}
|
||||
{belowSkillsSlot}
|
||||
|
||||
{/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */}
|
||||
{showRecentChats && !editing && <AgentRecentChats agentId={agent.id} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3Icon, MessageSquareTextIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useAgentFeedbackScore,
|
||||
type AnswerFeedbackReason,
|
||||
type FeedbackDimensionKey,
|
||||
} from "@/strategy-components/api/answer-feedback";
|
||||
|
||||
const DIMENSIONS: Array<{
|
||||
key: FeedbackDimensionKey;
|
||||
label: string;
|
||||
fallbackWeight: number;
|
||||
}> = [
|
||||
{ key: "correctness", label: "正确性", fallbackWeight: 30 },
|
||||
{ key: "completeness", label: "完整性", fallbackWeight: 20 },
|
||||
{ key: "evidence_grounding", label: "证据与依据", fallbackWeight: 20 },
|
||||
{ key: "relevance", label: "相关性", fallbackWeight: 15 },
|
||||
{ key: "actionability", label: "可操作性", fallbackWeight: 15 },
|
||||
];
|
||||
|
||||
const REASON_LABELS: Record<AnswerFeedbackReason, string> = {
|
||||
incorrect_or_incomplete: "不正确或不完整",
|
||||
unmet_expectations: "与期望不符",
|
||||
slow_or_problematic: "速度慢或存在问题",
|
||||
style_or_tone: "风格或语气",
|
||||
safety_or_legal: "安全或法律疑虑",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const CONFIDENCE_LABELS = {
|
||||
none: "暂无置信度",
|
||||
low: "低置信度",
|
||||
medium: "中等置信度",
|
||||
high: "高置信度",
|
||||
} as const;
|
||||
|
||||
function scoreText(value: number | null | undefined) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value.toFixed(1)
|
||||
: "—";
|
||||
}
|
||||
|
||||
function percent(value: number | null | undefined) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
||||
const normalized = value <= 1 ? value * 100 : value;
|
||||
return Math.max(0, Math.min(100, normalized));
|
||||
}
|
||||
|
||||
function weightPercent(value: number | undefined, fallback: number) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
||||
return value <= 1 ? Math.round(value * 100) : Math.round(value);
|
||||
}
|
||||
|
||||
export function AgentFeedbackScoreCard({ agentId }: { agentId: string }) {
|
||||
const { data, isLoading, isError } = useAgentFeedbackScore(agentId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4" aria-label="回答评价加载中">
|
||||
<div className="bg-muted h-4 w-24 animate-pulse rounded" />
|
||||
<div className="bg-muted mt-4 h-12 w-28 animate-pulse rounded-lg" />
|
||||
<div className="mt-4 space-y-2">
|
||||
{DIMENSIONS.map((item) => (
|
||||
<div key={item.key} className="bg-muted h-3 animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="flex items-center gap-1.5 text-sm font-semibold">
|
||||
<BarChart3Icon className="size-3.5" />
|
||||
回答评价
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs">评价数据暂不可用</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const positiveRate = data.sample_count
|
||||
? (data.positive_count / data.sample_count) * 100
|
||||
: null;
|
||||
const taskSuccessRate = percent(data.task_success_rate);
|
||||
const topReasons = Object.entries(data.reason_breakdown ?? {})
|
||||
.filter((entry): entry is [AnswerFeedbackReason, { count: number; rate: number }] =>
|
||||
Boolean(entry[1]?.count),
|
||||
)
|
||||
.sort((left, right) => right[1].count - left[1].count)
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
<BarChart3Icon className="size-3.5" />
|
||||
回答评价
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs">用户评价分</p>
|
||||
</div>
|
||||
{data.score_band && data.score !== null && (
|
||||
<span className="border-primary/20 bg-primary/5 text-primary rounded-full border px-2 py-1 text-[11px] font-medium">
|
||||
{data.score_band.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-end gap-2">
|
||||
{data.score === null ? (
|
||||
<span className="text-muted-foreground text-xl font-semibold">暂无评价</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-bold tracking-tight tabular-nums">
|
||||
{scoreText(data.score)}
|
||||
</span>
|
||||
<span className="text-muted-foreground mb-1 text-xs">/ 100</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px]">
|
||||
<span>{data.sample_count} 条评价</span>
|
||||
<span>
|
||||
{CONFIDENCE_LABELS[data.confidence_level]}
|
||||
{` ${Math.round(data.confidence * 100)}%`}
|
||||
</span>
|
||||
<span>
|
||||
好评率 {positiveRate === null ? "—" : `${positiveRate.toFixed(0)}%`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{data.sample_count > 0 && (
|
||||
<div className="mt-4 space-y-3" aria-label="回答评价维度">
|
||||
{DIMENSIONS.map((item) => {
|
||||
const dimension = data.dimensions[item.key];
|
||||
const value = percent(dimension?.score);
|
||||
return (
|
||||
<div key={item.key}>
|
||||
<div className="mb-1 flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-foreground">
|
||||
{item.label}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{weightPercent(dimension?.weight, item.fallbackWeight)}%
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{value === null ? "—" : value.toFixed(1)}
|
||||
{dimension?.evidence_count
|
||||
? ` · ${dimension.evidence_count} 条依据`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted h-1.5 overflow-hidden rounded-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width]",
|
||||
value !== null && value >= 80
|
||||
? "bg-emerald-500"
|
||||
: value !== null && value >= 60
|
||||
? "bg-amber-500"
|
||||
: "bg-rose-500",
|
||||
)}
|
||||
style={{ width: `${value ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(data.task_score !== null || taskSuccessRate !== null) && (
|
||||
<div className="bg-muted/50 mt-4 flex items-center justify-between rounded-lg px-3 py-2 text-xs">
|
||||
<span className="flex items-center gap-1.5 font-medium">
|
||||
<MessageSquareTextIcon className="size-3.5" />
|
||||
任务成功与稳定性
|
||||
</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{data.task_score !== null && `评分 ${scoreText(data.task_score)}`}
|
||||
{data.task_score !== null && taskSuccessRate !== null && " · "}
|
||||
{taskSuccessRate !== null && `成功率 ${taskSuccessRate.toFixed(0)}%`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topReasons.length > 0 && (
|
||||
<div className="mt-4 border-t pt-3">
|
||||
<p className="text-muted-foreground mb-2 text-[11px] font-medium">主要改进反馈</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{topReasons.map(([reason, breakdown]) => (
|
||||
<span
|
||||
key={reason}
|
||||
className="bg-muted text-muted-foreground rounded-full px-2 py-1 text-[11px]"
|
||||
>
|
||||
{REASON_LABELS[reason]} · {breakdown.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground/80 mt-4 border-t pt-3 text-[10px] leading-relaxed">
|
||||
该分数仅反映用户对回答结果的评价,不代表完整任务轨迹、工具调用或技能能力。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import { canViewAgentFeedbackScore } from "./agent-feedback-visibility.ts";
|
||||
|
||||
test("allows only the canonical admin role to view agent feedback scores", () => {
|
||||
assert.equal(canViewAgentFeedbackScore({ system_role: "admin" }), true);
|
||||
assert.equal(canViewAgentFeedbackScore({ system_role: "user" }), false);
|
||||
assert.equal(canViewAgentFeedbackScore({ system_role: "Admin" }), false);
|
||||
});
|
||||
|
||||
test("fails closed when authentication or its role is unavailable", () => {
|
||||
assert.equal(canViewAgentFeedbackScore(null), false);
|
||||
assert.equal(canViewAgentFeedbackScore(undefined), false);
|
||||
assert.equal(canViewAgentFeedbackScore({ system_role: "" }), false);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { LoginByUsernameResponse } from "@/core/auth";
|
||||
|
||||
/**
|
||||
* Agent-level answer scores are an administrative quality-control view.
|
||||
* Keep the role check strict so missing, stale or unknown auth roles fail
|
||||
* closed instead of exposing aggregate evaluation data.
|
||||
*/
|
||||
export function canViewAgentFeedbackScore(
|
||||
auth: Pick<LoginByUsernameResponse, "system_role"> | null | undefined,
|
||||
): boolean {
|
||||
return auth?.system_role === "admin";
|
||||
}
|
||||
@@ -16,8 +16,16 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { isSentimentAgentVisible } from "@/core/sentiment-agent/config";
|
||||
import { usePrivateSquareStatus, usePublishSquares, useViewMode } from "@/core/system-settings/hooks";
|
||||
import {
|
||||
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
|
||||
WX_RESTRICTED_TAB_NANJING_DB,
|
||||
isWxRestrictedPlaceholderTab,
|
||||
wxRestrictedDefaultSquareTabValue,
|
||||
wxRestrictedNanjingTabValue,
|
||||
} from "@/core/system-settings/wx-type-filter";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
import { SentimentAgentCard } from "./sentiment-agent-card";
|
||||
@@ -34,9 +42,15 @@ const PAGE_SIZE = 12;
|
||||
// password never persists beyond the browsing session.
|
||||
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||
|
||||
export function AgentGallery() {
|
||||
export function AgentGallery({
|
||||
restrictWxTypes = false,
|
||||
}: {
|
||||
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
|
||||
restrictWxTypes?: boolean;
|
||||
} = {}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const routeBase = useRouteBase();
|
||||
const sentimentAgentVisible = isSentimentAgentVisible();
|
||||
// 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
@@ -52,6 +66,12 @@ export function AgentGallery() {
|
||||
// 默认广场固定排在「我的」之后;舆情分析是依附在默认广场后的前端虚拟类型,
|
||||
// 其余自定义广场继续保持后端给出的顺序。
|
||||
const [defaultSquare, ...additionalSquares] = squareTabs;
|
||||
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
|
||||
publishSquares?.default_square_id,
|
||||
);
|
||||
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
|
||||
const wxPlaceholderTab =
|
||||
restrictWxTypes && isWxRestrictedPlaceholderTab(activeTab);
|
||||
// Publisher filter: click a card's 发布者 to keep only that user's agents.
|
||||
// {id} is the owner user_id (server filter), {label} the desensitized name.
|
||||
const [publisher, setPublisher] = useState<{ id: string; label: string } | null>(
|
||||
@@ -72,6 +92,30 @@ 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 (restrictWxTypes) {
|
||||
if (
|
||||
activeTab === WX_RESTRICTED_TAB_NANJING_DB &&
|
||||
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
|
||||
) {
|
||||
setActiveTab(nanjingTabValue);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeTab === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
|
||||
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
|
||||
) {
|
||||
setActiveTab(defaultSquareTabValue);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeTab !== "mine" &&
|
||||
activeTab !== defaultSquareTabValue &&
|
||||
activeTab !== nanjingTabValue
|
||||
) {
|
||||
setActiveTab("mine");
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。
|
||||
if (activeTab === "builtin" && !isAdmin) {
|
||||
setActiveTab("mine");
|
||||
@@ -87,7 +131,14 @@ export function AgentGallery() {
|
||||
) {
|
||||
setActiveTab("mine");
|
||||
}
|
||||
}, [activeTab, squareTabs, isAdmin]);
|
||||
}, [
|
||||
activeTab,
|
||||
squareTabs,
|
||||
isAdmin,
|
||||
restrictWxTypes,
|
||||
defaultSquareTabValue,
|
||||
nanjingTabValue,
|
||||
]);
|
||||
|
||||
const { data: privateStatus } = usePrivateSquareStatus();
|
||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||
@@ -128,10 +179,13 @@ export function AgentGallery() {
|
||||
sort,
|
||||
page: mainPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab !== "private" && activeTab !== "sentiment",
|
||||
enabled:
|
||||
activeTab !== "private" &&
|
||||
activeTab !== "sentiment" &&
|
||||
!wxPlaceholderTab,
|
||||
owner: publisher?.id,
|
||||
// Each square tab fetches only that square's agents.
|
||||
square: isSquareTab ? activeTab : undefined,
|
||||
square: isSquareTab && !wxPlaceholderTab ? activeTab : undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -169,11 +223,13 @@ export function AgentGallery() {
|
||||
(activeTab !== "private" || privateUnlocked);
|
||||
|
||||
const activeTotal =
|
||||
activeTab === "sentiment"
|
||||
? 1
|
||||
: activeTab === "private"
|
||||
? privateTotal
|
||||
: mainTotal;
|
||||
wxPlaceholderTab
|
||||
? 0
|
||||
: activeTab === "sentiment"
|
||||
? 1
|
||||
: activeTab === "private"
|
||||
? privateTotal
|
||||
: mainTotal;
|
||||
const setTabPage = (tab: string, page: number) =>
|
||||
setPages((prev) => ({ ...prev, [tab]: page }));
|
||||
|
||||
@@ -404,6 +460,14 @@ export function AgentGallery() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title="打开智能体广场视图"
|
||||
onClick={() => router.push(`${routeBase}/agents/plaza`)}
|
||||
>
|
||||
广场视图
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -442,6 +506,14 @@ export function AgentGallery() {
|
||||
className="w-auto max-w-full flex-wrap justify-start self-start"
|
||||
data-agent-guide="tabs"
|
||||
>
|
||||
{restrictWxTypes ? (
|
||||
<>
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
|
||||
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
{defaultSquare && (
|
||||
<TabsTrigger
|
||||
@@ -475,6 +547,8 @@ export function AgentGallery() {
|
||||
内置智能体
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
@@ -545,7 +619,41 @@ export function AgentGallery() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{squareTabs.map((sq) => (
|
||||
{restrictWxTypes ? (
|
||||
<>
|
||||
<TabsContent
|
||||
value={defaultSquareTabValue}
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{wxPlaceholderTab && activeTab === defaultSquareTabValue
|
||||
? renderEmpty("该广场暂无智能体")
|
||||
: renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
isFetching: mainFetching,
|
||||
tab: defaultSquareTabValue,
|
||||
emptyDescription: "该广场暂无智能体",
|
||||
})}
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value={nanjingTabValue}
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{wxPlaceholderTab && activeTab === nanjingTabValue
|
||||
? renderEmpty("该类型暂无智能体")
|
||||
: renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
isFetching: mainFetching,
|
||||
tab: nanjingTabValue,
|
||||
emptyDescription: "该类型暂无智能体",
|
||||
})}
|
||||
</TabsContent>
|
||||
</>
|
||||
) : (
|
||||
squareTabs.map((sq) => (
|
||||
<TabsContent
|
||||
key={sq.id}
|
||||
value={sq.id}
|
||||
@@ -560,7 +668,8 @@ export function AgentGallery() {
|
||||
emptyDescription: `「${sq.name}」广场暂时还没有智能体。`,
|
||||
})}
|
||||
</TabsContent>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
|
||||
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
@@ -576,7 +685,7 @@ export function AgentGallery() {
|
||||
|
||||
{/* 舆情分析类型:固定一张虚拟智能体卡片(名称/简介可编辑),
|
||||
接口地址与鉴权在 runtime-config.js 配置。 */}
|
||||
{sentimentAgentVisible && (
|
||||
{!restrictWxTypes && sentimentAgentVisible && (
|
||||
<TabsContent
|
||||
value="sentiment"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
@@ -593,7 +702,7 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
{!restrictWxTypes && isAdmin && (
|
||||
<TabsContent value="builtin" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
@@ -606,7 +715,7 @@ export function AgentGallery() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{privateFeatureAvailable && (
|
||||
{!restrictWxTypes && privateFeatureAvailable && (
|
||||
<TabsContent
|
||||
value="private"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
|
||||
@@ -278,6 +278,7 @@ export function AgentGuide({
|
||||
autoStart?: boolean;
|
||||
}) {
|
||||
const [current, setCurrent] = useState(-1);
|
||||
const [appDialogOpen, setAppDialogOpen] = useState(false);
|
||||
const stepsRef = useRef<GuideStep[]>([]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
@@ -317,7 +318,32 @@ export function AgentGuide({
|
||||
setCurrent(-1);
|
||||
}, [stage]);
|
||||
|
||||
if (!eligible) return null;
|
||||
// TDesign Guide 遮罩是 z-index 999997+。编辑智能体等业务弹窗只有 z-200,
|
||||
// 引导若继续高亮页头按钮,会把弹窗连同关闭按钮一起盖住。业务 Dialog 打开时
|
||||
// 先卸掉引导层,关掉后再从当前步恢复。
|
||||
useEffect(() => {
|
||||
if (!eligible) return;
|
||||
const sync = () => {
|
||||
setAppDialogOpen(
|
||||
Boolean(document.querySelector('[data-slot="dialog-content"]')),
|
||||
);
|
||||
};
|
||||
sync();
|
||||
const observer = new MutationObserver(sync);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
}, [eligible]);
|
||||
|
||||
// TDesign 1.18 still handles resize when current=-1 and dereferences an
|
||||
// undefined step. Keep its listener mounted only during an active guide.
|
||||
if (
|
||||
!eligible ||
|
||||
appDialogOpen ||
|
||||
current < 0 ||
|
||||
current >= stepsRef.current.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Guide
|
||||
|
||||
@@ -4,7 +4,13 @@ import { MessageSquareIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useThreadsPage } from "@/core/threads/hooks";
|
||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
||||
import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -26,6 +32,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
const threads = (data?.threads ?? []).filter(
|
||||
(th) => !isBootstrapThread(th) && !isHiddenFromRecentChats(th),
|
||||
);
|
||||
const projectThreadNames = useProjectThreadNames(threads);
|
||||
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
@@ -45,6 +52,10 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
{threads.map((thread) => {
|
||||
const href = pathOfThread(thread);
|
||||
const isActive = href === pathname;
|
||||
const projectInfo = myProjectConversationInfoOfThread(
|
||||
thread,
|
||||
projectThreadNames,
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={thread.thread_id}
|
||||
@@ -52,19 +63,37 @@ export function AgentRecentChats({ agentId }: { agentId: string }) {
|
||||
title={desensitize(titleOfThread(thread, "新对话"))}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-sm transition-colors",
|
||||
projectInfo && "min-h-12",
|
||||
isActive
|
||||
? "bg-primary/5 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MessageSquareIcon
|
||||
{projectInfo ? (
|
||||
<MyProjectThreadIcon type={projectInfo.type} />
|
||||
) : (
|
||||
<MessageSquareIcon
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"size-3.5 shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground/70",
|
||||
"min-w-0 flex-1",
|
||||
projectInfo && "flex flex-col items-start gap-0.5",
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate">
|
||||
{desensitize(titleOfThread(thread, "新对话"))}
|
||||
</span>
|
||||
{projectInfo && (
|
||||
<MyProjectThreadBadge
|
||||
projectName={projectInfo.projectName}
|
||||
type={projectInfo.type}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, "新对话"))}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isSkillOffline } from "@/core/skill-availability";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -53,6 +55,7 @@ export function EnabledSkillList({
|
||||
onViewDetail?: (name: string) => void;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const byName = useMemo(
|
||||
() => new Map(skills.map((s) => [s.name, s])),
|
||||
[skills],
|
||||
@@ -79,12 +82,14 @@ export function EnabledSkillList({
|
||||
<div className="space-y-2">
|
||||
{skillNames.map((name) => {
|
||||
const skill = byName.get(name);
|
||||
const offline = isSkillOffline(skill);
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={cn(
|
||||
"group/skill bg-background/60 flex items-start gap-2.5 rounded-xl border p-2.5 transition-colors",
|
||||
onViewDetail && "hover:border-primary/40 hover:bg-accent/40",
|
||||
offline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
|
||||
)}
|
||||
>
|
||||
<div className="bg-primary/10 text-primary flex size-8 shrink-0 items-center justify-center rounded-lg text-sm font-semibold">
|
||||
@@ -108,6 +113,15 @@ export function EnabledSkillList({
|
||||
{skillSourceLabel(skill)}
|
||||
</Badge>
|
||||
)}
|
||||
{offline && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 border-red-500/35 bg-red-500/5 text-[10px] text-red-700 dark:text-red-300"
|
||||
title={t.settings.skills.badgeOfflineTitle}
|
||||
>
|
||||
{t.settings.skills.badgeOffline}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{skill
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Skill } from "@/core/skills";
|
||||
import { isSkillOffline } from "@/core/skill-availability";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -28,7 +29,11 @@ const PAGE_SIZE = 5;
|
||||
const ALL_CATEGORIES = "__all__";
|
||||
|
||||
interface SkillTransferSelectorProps {
|
||||
/** Full pool of selectable skills (caller pre-filters by `enabled`). */
|
||||
/**
|
||||
* Full visible skill pool. Offline skills are never offered on the left,
|
||||
* but an already-selected offline skill stays visible on the right so it
|
||||
* can be removed without silently changing the persisted whitelist.
|
||||
*/
|
||||
skills: Skill[];
|
||||
/** Currently selected skill names — the persisted `skills: string[]`. */
|
||||
selected: string[];
|
||||
@@ -91,7 +96,9 @@ export function SkillTransferSelector({
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const skill of skills) {
|
||||
if (skill.category?.trim()) set.add(skill.category.trim());
|
||||
if (!isSkillOffline(skill) && skill.category?.trim()) {
|
||||
set.add(skill.category.trim());
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||||
}, [skills]);
|
||||
@@ -102,6 +109,7 @@ export function SkillTransferSelector({
|
||||
const availableSkills = useMemo(() => {
|
||||
return skills.filter(
|
||||
(skill) =>
|
||||
!isSkillOffline(skill) &&
|
||||
!selectedSet.has(skill.name) &&
|
||||
matchesQuery(skill, query) &&
|
||||
(category === ALL_CATEGORIES || skill.category?.trim() === category),
|
||||
@@ -384,6 +392,7 @@ function SkillItemCard({
|
||||
onPick,
|
||||
onViewDetail,
|
||||
}: SkillItemCardProps) {
|
||||
const isOffline = isSkillOffline(skill);
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -398,6 +407,7 @@ function SkillItemCard({
|
||||
title={isSelected ? "点击移除" : "点击添加"}
|
||||
className={cn(
|
||||
"group/item relative cursor-pointer rounded-md border px-2.5 py-1.5 pr-7 transition-colors",
|
||||
isOffline && "border-dashed bg-muted/50",
|
||||
isSelected
|
||||
? "border-primary/60 bg-primary/5 hover:border-primary dark:border-[#3B82F6] dark:bg-[#0a3568]"
|
||||
: "hover:border-primary/40 hover:bg-accent/40 dark:border-[#213D5E] dark:bg-[#02244C] dark:hover:border-[#3B82F6]/60",
|
||||
@@ -425,6 +435,11 @@ function SkillItemCard({
|
||||
>
|
||||
{skillLabel(skill)}
|
||||
</div>
|
||||
{isOffline && (
|
||||
<div className="text-destructive truncate text-[10px]">
|
||||
已下线,仅可移除
|
||||
</div>
|
||||
)}
|
||||
{skill.description?.trim() && (
|
||||
<div className="text-muted-foreground truncate text-[11px]">
|
||||
{desensitize(skill.description)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ExternalLinkIcon,
|
||||
EyeIcon,
|
||||
FileType2Icon,
|
||||
GlobeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
@@ -17,11 +18,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { defaultUrlTransform } from "react-markdown";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import {
|
||||
Artifact,
|
||||
@@ -58,17 +59,13 @@ import { publishArtifactToKnowledge } from "@/core/agents/api";
|
||||
import { useAgent } from "@/core/agents/hooks";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import { siteStudioRoute, useWebAppCapability } from "@/core/sites/api";
|
||||
import {
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
DOCUMENT_REWRITE_STARTED_EVENT,
|
||||
DOCUMENT_REWRITE_UNDONE_EVENT,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import {
|
||||
clearArtifactRewriteFallback,
|
||||
completeArtifactRewriteFallback,
|
||||
primeArtifactRewriteFallback,
|
||||
} from "@/core/document-rewrite/local-fallback";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
} from "@/core/artifacts/download";
|
||||
@@ -85,7 +82,11 @@ import {
|
||||
import { isArtifactApiPath } from "@/core/artifacts/streaming-write-preview";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||
import {
|
||||
SafeStreamdown,
|
||||
streamdownPlugins,
|
||||
useStreamdownMermaidPngDownload,
|
||||
} from "@/core/streamdown";
|
||||
import {
|
||||
checkCodeFile,
|
||||
getFileName,
|
||||
@@ -209,6 +210,22 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别 Web App 站点清单:`outputs/<site>/site.json`(兼容 `write-file:` 虚拟 URL
|
||||
* 与查询串)→ 返回站点目录名;其它文件返回 null(按钮不渲染,零行为变化)。
|
||||
*/
|
||||
function extractSiteStudioTarget(filepath: string): string | null {
|
||||
const normalized = filepath.replace(/^write-file:/, "").split("?")[0];
|
||||
if (!/(^|\/)site\.json$/.test(normalized)) return null;
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
const idx = parts.lastIndexOf("site.json");
|
||||
if (idx <= 0) return null;
|
||||
const siteName = parts[idx - 1];
|
||||
if (!siteName || siteName === "outputs") return null;
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(siteName)) return null;
|
||||
return siteName;
|
||||
}
|
||||
|
||||
export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
@@ -266,8 +283,16 @@ export function ArtifactFileDetail({
|
||||
headerLeadingActionBare?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { artifacts, setOpen, select, setGeneratingArtifact } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const navigate = useNavigate();
|
||||
// Web App 站点清单(outputs/<site>/site.json)→「站点预览」入口。
|
||||
// capability 不可用(未启用/未部署)时按钮不渲染,原 UI 一字不变。
|
||||
const siteStudioTarget = useMemo(
|
||||
() => extractSiteStudioTarget(filepathFromProps),
|
||||
[filepathFromProps],
|
||||
);
|
||||
const webAppCapabilityQuery = useWebAppCapability(!!siteStudioTarget);
|
||||
const isWriteFileVirtual = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
@@ -414,6 +439,13 @@ export function ArtifactFileDetail({
|
||||
thinking: "",
|
||||
});
|
||||
const documentRewriteActive = documentRewrite.isActive;
|
||||
const generationLockId = useId();
|
||||
useLayoutEffect(() => {
|
||||
const owner = `preview:${generationLockId}`;
|
||||
const generating = (writeFileInFlight && thread.isLoading) || documentRewriteActive;
|
||||
setGeneratingArtifact(owner, generating ? filepathFromProps : null);
|
||||
return () => setGeneratingArtifact(owner, null);
|
||||
}, [documentRewriteActive, filepathFromProps, generationLockId, setGeneratingArtifact, thread.isLoading, writeFileInFlight]);
|
||||
const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true;
|
||||
const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4;
|
||||
const displayContent = documentRewriteActive
|
||||
@@ -527,10 +559,17 @@ export function ArtifactFileDetail({
|
||||
) => {
|
||||
if (!rewritePresentation) return;
|
||||
switch (event.type) {
|
||||
case "job_started":
|
||||
rewritePresentation.update(presentationId, {
|
||||
jobId: event.jobId,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
});
|
||||
break;
|
||||
case "snapshot_fixed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "requirements",
|
||||
fileName: event.fileName || getFileName(filepath),
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
originalCharCount: event.originalCharCount,
|
||||
requirementsPlan: "",
|
||||
});
|
||||
@@ -595,6 +634,7 @@ export function ArtifactFileDetail({
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: event.content,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
versionId: event.versionId ?? null,
|
||||
committedHash: event.committedHash,
|
||||
});
|
||||
@@ -612,7 +652,11 @@ export function ArtifactFileDetail({
|
||||
rewritePresentation.update(presentationId, { stage: "cancelled", error: event.message });
|
||||
break;
|
||||
case "error":
|
||||
console.error("AI 全文改写历史恢复失败:", event.message);
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error: event.message,
|
||||
diagnostic: event.diagnostic ?? null,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -638,7 +682,15 @@ export function ArtifactFileDetail({
|
||||
const historicalPresentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
fileName: getFileName(filepath),
|
||||
jobId: historical.id,
|
||||
outputPath: typeof historicalResult.outputPath === "string"
|
||||
? historicalResult.outputPath
|
||||
: historical.outputPath,
|
||||
fileName: getFileName(
|
||||
typeof historicalResult.outputPath === "string"
|
||||
? historicalResult.outputPath
|
||||
: historical.outputPath ?? filepath,
|
||||
),
|
||||
instruction: historical.request.instruction,
|
||||
style: historical.request.style,
|
||||
modelName: historical.request.modelName,
|
||||
@@ -663,7 +715,10 @@ export function ArtifactFileDetail({
|
||||
? "文件在改写期间已变化,未覆盖原文件。"
|
||||
: historical.status === "cancelled"
|
||||
? "已停止全文改写,原文件未被修改。"
|
||||
: historical.status === "failed" ? "全文改写未完成,原文件未被修改。" : null,
|
||||
: historical.status === "failed"
|
||||
? historical.errorMessage ?? "全文改写未完成,原文件未被修改。"
|
||||
: null,
|
||||
diagnostic: historical.diagnostic ?? null,
|
||||
});
|
||||
}
|
||||
const result = latest.result;
|
||||
@@ -672,26 +727,16 @@ export function ArtifactFileDetail({
|
||||
: null;
|
||||
const originalContent = latest.request.originalContent || draftContent;
|
||||
const rewrittenContent = typeof result.content === "string" ? result.content : null;
|
||||
const versionSnapshotFailed = result.versionSnapshotFailed === true;
|
||||
const committedHash = typeof result.committedHash === "string" ? result.committedHash : null;
|
||||
if (!versionSnapshotFailed) {
|
||||
clearArtifactRewriteFallback(threadId, filepath);
|
||||
}
|
||||
const localFallbackAvailable =
|
||||
versionSnapshotFailed &&
|
||||
latest.status === "completed" &&
|
||||
result.status !== "conflict" &&
|
||||
committedHash !== null &&
|
||||
completeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent,
|
||||
committedHash,
|
||||
});
|
||||
const outputPath = typeof result.outputPath === "string"
|
||||
? result.outputPath
|
||||
: latest.outputPath;
|
||||
const presentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
source: { kind: "artifact", id: threadId, path: filepath },
|
||||
fileName: getFileName(filepath),
|
||||
jobId: latest.id,
|
||||
outputPath,
|
||||
fileName: getFileName(outputPath ?? filepath),
|
||||
instruction: latest.request.instruction,
|
||||
style: latest.request.style,
|
||||
modelName: latest.request.modelName,
|
||||
@@ -700,7 +745,6 @@ export function ArtifactFileDetail({
|
||||
originalContent,
|
||||
rewrittenContent,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "",
|
||||
});
|
||||
if (!presentationId || !rewritePresentation) return;
|
||||
@@ -713,28 +757,18 @@ export function ArtifactFileDetail({
|
||||
warnings: summary?.validationWarnings ?? [],
|
||||
versionId: typeof result.versionId === "string" ? result.versionId : null,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
error: result.status === "conflict" ? "文件在改写期间已变化,未覆盖原文件。" : null,
|
||||
error: result.status === "conflict" ? "改写版文件未生成。" : null,
|
||||
});
|
||||
if (rewrittenContent && result.status !== "conflict") {
|
||||
setDraftContent(rewrittenContent);
|
||||
setHasLocalEdits(versionSnapshotFailed);
|
||||
if (versionSnapshotFailed) {
|
||||
console.error(
|
||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||
);
|
||||
}
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||
{ content: rewrittenContent, url: undefined },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (latest.status === "failed" || latest.status === "cancelled") {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: latest.status === "cancelled" ? "cancelled" : "error",
|
||||
summary,
|
||||
error: latest.status === "cancelled"
|
||||
? "已停止全文改写,原文件未被修改。"
|
||||
: latest.errorMessage ?? "全文改写未完成,原文件未被修改。",
|
||||
diagnostic: latest.status === "failed" ? latest.diagnostic ?? null : null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -757,32 +791,19 @@ export function ArtifactFileDetail({
|
||||
signal,
|
||||
),
|
||||
onEvent: (event) => updatePresentation(presentationId, event),
|
||||
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
||||
setDraftContent(rewritten);
|
||||
setHasLocalEdits(versionSnapshotFailed);
|
||||
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent,
|
||||
committedHash,
|
||||
});
|
||||
if (!versionSnapshotFailed) {
|
||||
clearArtifactRewriteFallback(threadId, filepath);
|
||||
}
|
||||
if (versionSnapshotFailed) {
|
||||
console.error(
|
||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||
);
|
||||
}
|
||||
onCommitted: (rewritten, { committedHash, outputPath }) => {
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
...(outputPath ? { outputPath } : {}),
|
||||
});
|
||||
},
|
||||
onFailure: ({ error, diagnostic }) => {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error,
|
||||
diagnostic: diagnostic ?? null,
|
||||
});
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: ["artifact", filepath, threadId, isMock] },
|
||||
{ content: rewritten, url: undefined },
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1115,6 +1136,7 @@ export function ArtifactFileDetail({
|
||||
setDocumentRewriteDialogOpen(false);
|
||||
setAllowAutoViewSwitch(false);
|
||||
setAutoScrollEnabled(true);
|
||||
setShowResumeAutoScroll(false);
|
||||
setViewMode("code");
|
||||
|
||||
// Do not make users manually save before using AI rewrite. Persist their
|
||||
@@ -1136,11 +1158,6 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
}
|
||||
|
||||
primeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent: draftContent,
|
||||
});
|
||||
const expectedHash = await sha256Content(draftContent);
|
||||
const presentationId = rewritePresentation?.create({
|
||||
threadId,
|
||||
@@ -1176,10 +1193,17 @@ export function ArtifactFileDetail({
|
||||
onEvent: (event) => {
|
||||
if (!presentationId || !rewritePresentation) return;
|
||||
switch (event.type) {
|
||||
case "job_started":
|
||||
rewritePresentation.update(presentationId, {
|
||||
jobId: event.jobId,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
});
|
||||
break;
|
||||
case "snapshot_fixed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "requirements",
|
||||
fileName: event.fileName || getFileName(filepath),
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
originalCharCount: event.originalCharCount,
|
||||
requirementsPlan: "",
|
||||
});
|
||||
@@ -1246,6 +1270,8 @@ export function ArtifactFileDetail({
|
||||
break;
|
||||
case "committed":
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: event.content,
|
||||
...(event.outputPath ? { outputPath: event.outputPath } : {}),
|
||||
versionId: event.versionId ?? null,
|
||||
committedHash: event.committedHash,
|
||||
});
|
||||
@@ -1262,70 +1288,41 @@ export function ArtifactFileDetail({
|
||||
error: event.message,
|
||||
});
|
||||
break;
|
||||
case "cancelled":
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "cancelled",
|
||||
error: event.message,
|
||||
});
|
||||
break;
|
||||
case "error":
|
||||
// The sandbox retains the visible draft and exposes manual
|
||||
// save instead of showing an error state to the user.
|
||||
console.error("AI 全文改写接口失败:", event.message);
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "error",
|
||||
error: event.message,
|
||||
diagnostic: event.diagnostic ?? null,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
onCommitted: (rewritten, { versionSnapshotFailed, committedHash }) => {
|
||||
setDraftContent(rewritten);
|
||||
setHasLocalEdits(versionSnapshotFailed);
|
||||
const localFallbackAvailable = versionSnapshotFailed && completeArtifactRewriteFallback({
|
||||
threadId,
|
||||
path: filepath,
|
||||
originalContent: draftContent,
|
||||
committedHash,
|
||||
});
|
||||
if (!versionSnapshotFailed) {
|
||||
clearArtifactRewriteFallback(threadId, filepath);
|
||||
}
|
||||
onCommitted: (rewritten, { committedHash, outputPath }) => {
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
rewrittenContent: rewritten,
|
||||
committedHash,
|
||||
localFallbackAvailable,
|
||||
...(outputPath ? { outputPath } : {}),
|
||||
});
|
||||
}
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
},
|
||||
{ content: rewritten, url: undefined },
|
||||
);
|
||||
if (versionSnapshotFailed) {
|
||||
console.error(
|
||||
"AI 全文改写已写入文件,但撤销版本快照保存失败;已启用手动保存。",
|
||||
);
|
||||
} else {
|
||||
toast.success("Markdown 全文改写完成,已安全替换原文件。");
|
||||
}
|
||||
toast.success("AI 改写完成,已生成独立的改写版文件。原文件保持不变。");
|
||||
},
|
||||
onFailure: ({ content, error }) => {
|
||||
// The rewrite transport can fail after the server has already
|
||||
// streamed text (or before the first token on a 404). Keep that
|
||||
// visible content in the sandbox, make Save actionable, and leave
|
||||
// diagnostics to the developer console only.
|
||||
const retainedContent = content || draftContent;
|
||||
setDraftContent(retainedContent);
|
||||
setHasLocalEdits(true);
|
||||
queryClient.setQueriesData(
|
||||
{
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
},
|
||||
{ content: retainedContent, url: undefined },
|
||||
);
|
||||
onFailure: ({ error, diagnostic }) => {
|
||||
if (presentationId && rewritePresentation) {
|
||||
rewritePresentation.update(presentationId, {
|
||||
stage: "completed",
|
||||
rewrittenContent: retainedContent,
|
||||
error: null,
|
||||
stage: "error",
|
||||
error,
|
||||
diagnostic: diagnostic ?? null,
|
||||
});
|
||||
}
|
||||
console.error("AI 全文改写接口失败,已保留沙箱内容供手动保存:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1354,6 +1351,28 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
}, [documentRewrite, documentRewritePresentationId, rewritePresentation]);
|
||||
|
||||
// The docked conversation composer is outside this artifact pane. Register
|
||||
// the same cancellation path so its standard square stop button can abort
|
||||
// both the local SSE reader and the durable rewrite job.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!documentRewriteActive ||
|
||||
!documentRewritePresentationId ||
|
||||
!rewritePresentation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return rewritePresentation.registerCancellation(
|
||||
documentRewritePresentationId,
|
||||
handleCancelDocumentRewrite,
|
||||
);
|
||||
}, [
|
||||
documentRewriteActive,
|
||||
documentRewritePresentationId,
|
||||
handleCancelDocumentRewrite,
|
||||
rewritePresentation,
|
||||
]);
|
||||
|
||||
// 发布入库:仅当当前路由是智能体会话(agents/:agent_id/chats/…)且该智能体
|
||||
// 配置了入库模板名(template_name)时,md 产物右上角才出现发布按钮。
|
||||
const { agent_id: routeAgentId } = useParams<{ agent_id?: string }>();
|
||||
@@ -1427,7 +1446,9 @@ export function ArtifactFileDetail({
|
||||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([prepareGeneratedHtml(source)], {
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(source),
|
||||
], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1454,7 +1475,11 @@ export function ArtifactFileDetail({
|
||||
: language === "markdown"
|
||||
? "text/markdown;charset=utf-8"
|
||||
: "text/plain;charset=utf-8";
|
||||
const blob = new Blob([source], { type: blobType });
|
||||
const downloadSource =
|
||||
language === "html"
|
||||
? prepareGeneratedHtml(source)
|
||||
: source;
|
||||
const blob = new Blob([downloadSource], { type: blobType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -1588,7 +1613,7 @@ export function ArtifactFileDetail({
|
||||
label={documentRewriteActive ? "正在全文改写" : "AI 全文改写"}
|
||||
tooltip={
|
||||
documentRewriteActive
|
||||
? "正在生成临时草稿,原文件尚未被覆盖"
|
||||
? "正在生成独立改写版文件,原文件保持不变;点击停止"
|
||||
: hasLocalEdits
|
||||
? "将先保存当前 Markdown 修改,再开始改写"
|
||||
: "使用 AI 重写当前完整 Markdown"
|
||||
@@ -1596,7 +1621,13 @@ export function ArtifactFileDetail({
|
||||
// Keep the entry available after a completed rewrite and when
|
||||
// the user has local edits. Starting with local edits saves
|
||||
// them automatically in handleStartDocumentRewrite.
|
||||
onClick={() => setDocumentRewriteDialogOpen(true)}
|
||||
onClick={() => {
|
||||
if (documentRewriteActive) {
|
||||
handleCancelDocumentRewrite();
|
||||
return;
|
||||
}
|
||||
setDocumentRewriteDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||
@@ -1647,6 +1678,16 @@ export function ArtifactFileDetail({
|
||||
onClick={handleOpenInNewWindow}
|
||||
/>
|
||||
)}
|
||||
{siteStudioTarget && webAppCapabilityQuery.data?.available && (
|
||||
<ArtifactAction
|
||||
icon={GlobeIcon}
|
||||
label="站点预览"
|
||||
tooltip="打开站点预览工作室(多页面站点 / 流程图 / 地图)"
|
||||
onClick={() =>
|
||||
navigate(siteStudioRoute(threadId, siteStudioTarget))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
@@ -1713,27 +1754,6 @@ export function ArtifactFileDetail({
|
||||
正在改写 Markdown
|
||||
</div>
|
||||
)}
|
||||
{documentRewriteActive && (
|
||||
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||||
<LoaderIcon className="size-3.5 animate-spin" />
|
||||
{documentRewrite.run.stage === "requirements"
|
||||
? "正在理解改写需求,原文件未被修改"
|
||||
: documentRewrite.run.stage === "validation"
|
||||
? "正在校验 Markdown"
|
||||
: documentRewrite.run.stage === "committing"
|
||||
? "正在安全替换原文件"
|
||||
: "正在写入临时草稿 · " +
|
||||
documentRewrite.run.writtenCharCount.toLocaleString() +
|
||||
" 字"}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 rounded px-1.5 py-0.5 font-medium hover:bg-[#ead9a6]"
|
||||
onClick={handleCancelDocumentRewrite}
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showResumeAutoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -2094,7 +2114,9 @@ export function ArtifactFilePreview({
|
||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
||||
// of navigating the sandboxed blob: document (which blanks the frame).
|
||||
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
|
||||
const blob = new Blob([
|
||||
prepareGeneratedHtml(content ?? ""),
|
||||
], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -2147,7 +2169,7 @@ export function ArtifactFilePreview({
|
||||
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
||||
)}
|
||||
>
|
||||
<Streamdown
|
||||
<SafeStreamdown
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
// react-markdown's safe default strips unknown protocols before the
|
||||
@@ -2172,7 +2194,7 @@ export function ArtifactFilePreview({
|
||||
}}
|
||||
>
|
||||
{previewContent}
|
||||
</Streamdown>
|
||||
</SafeStreamdown>
|
||||
{activeCursor && (
|
||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getFileExtensionDisplayName,
|
||||
getFileIcon,
|
||||
getFileName,
|
||||
isWordDocumentFile,
|
||||
} from "@/core/utils/files";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -96,8 +97,8 @@ export function ArtifactFileList({
|
||||
{files.map((file) => (
|
||||
<Card
|
||||
key={file}
|
||||
className="relative cursor-pointer p-3"
|
||||
onClick={() => handleClick(file)}
|
||||
className={cn("relative p-3", !isWordDocumentFile(file) && "cursor-pointer")}
|
||||
onClick={isWordDocumentFile(file) ? undefined : () => handleClick(file)}
|
||||
>
|
||||
<CardHeader className="grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 pr-2 pl-1">
|
||||
<CardTitle className="relative min-w-0 pl-8 leading-tight [overflow-wrap:anywhere] break-words">
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { env } from "@/env";
|
||||
import { resolveWriteFilePath } from "@/core/artifacts/write-file-in-flight";
|
||||
|
||||
export function artifactPreviewKey(artifact: string): string {
|
||||
if (!artifact.startsWith("write-file:")) {
|
||||
@@ -38,6 +40,8 @@ export interface ArtifactsContextType {
|
||||
select: (artifact: string, autoSelect?: boolean) => void;
|
||||
/** Open/update a streamed write without overriding a manual close of that call. */
|
||||
openStreamedArtifact: (artifact: string, previewKey: string) => boolean;
|
||||
/** Register the file being generated; remove this owner's lock on completion. */
|
||||
setGeneratingArtifact: (owner: string, artifact: string | null) => void;
|
||||
deselect: () => void;
|
||||
|
||||
open: boolean;
|
||||
@@ -86,6 +90,26 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [preOpen, setPreOpen] = useState(false);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const suppressedAutoOpenKeyRef = useRef<string | null>(null);
|
||||
const generatingArtifactsRef = useRef(new Map<string, string>());
|
||||
const setGeneratingArtifact = useCallback((owner: string, artifact: string | null) => {
|
||||
if (artifact) generatingArtifactsRef.current.set(owner, artifact);
|
||||
else generatingArtifactsRef.current.delete(owner);
|
||||
}, []);
|
||||
const canSwitchArtifact = useCallback((artifact: string, notify: boolean) => {
|
||||
for (const generating of generatingArtifactsRef.current.values()) {
|
||||
if (
|
||||
artifactPreviewKey(generating) === artifactPreviewKey(artifact) ||
|
||||
resolveWriteFilePath(generating) === resolveWriteFilePath(artifact)
|
||||
) continue;
|
||||
if (notify) {
|
||||
toast.info("当前文件正在生成,请稍等片刻,完成后就可以切换查看其他文件。", {
|
||||
id: "artifact-generation-switch-locked",
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
// 供稳定的 setOpen 回调同步读取。写文件流会高频调用 select/setOpen,避免每次
|
||||
// Context 状态变化都重建 setter 并触发依赖它的布局 effect。
|
||||
const selectedArtifactKeyRef = useRef<string | null>(selectedArtifactKey);
|
||||
@@ -129,6 +153,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
|
||||
const select = useCallback(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
if (!canSwitchArtifact(artifact, !autoSelect)) return;
|
||||
const previewKey = artifactPreviewKey(artifact);
|
||||
selectedArtifactKeyRef.current = previewKey;
|
||||
setSelectedArtifact((previous) => (previous === artifact ? previous : artifact));
|
||||
@@ -140,11 +165,12 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
setAutoSelect((previous) => (previous ? false : previous));
|
||||
}
|
||||
},
|
||||
[sidebar],
|
||||
[canSwitchArtifact, sidebar],
|
||||
);
|
||||
|
||||
const openStreamedArtifact = useCallback(
|
||||
(artifact: string, previewKey: string) => {
|
||||
if (!canSwitchArtifact(artifact, false)) return false;
|
||||
if (suppressedAutoOpenKeyRef.current === previewKey) {
|
||||
return false;
|
||||
}
|
||||
@@ -180,10 +206,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[applyOpen, cancelPendingStreamedOpen, sidebar],
|
||||
[applyOpen, canSwitchArtifact, cancelPendingStreamedOpen, sidebar],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
generatingArtifactsRef.current.clear();
|
||||
selectedArtifactKeyRef.current = null;
|
||||
setSelectedArtifact((previous) => (previous === null ? previous : null));
|
||||
setSelectedArtifactKey((previous) => (previous === null ? previous : null));
|
||||
@@ -224,6 +251,7 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
selectedArtifactKey,
|
||||
select,
|
||||
openStreamedArtifact,
|
||||
setGeneratingArtifact,
|
||||
deselect,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { DocumentRewritePresentationProvider } from "@/core/document-rewrite/context";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { useUploadPolicy } from "@/core/uploads";
|
||||
|
||||
/**
|
||||
* Shared providers required by every full chat surface.
|
||||
*
|
||||
* Keep this in a small standalone module: importing it from a route must not
|
||||
* eagerly evaluate the complete WorkspaceRoutes tree and all of its pages.
|
||||
*/
|
||||
export function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
const { data: uploadPolicy } = useUploadPolicy();
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<DocumentRewritePresentationProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider uploadPolicy={uploadPolicy}>
|
||||
{children}
|
||||
</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</DocumentRewritePresentationProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
@@ -19,11 +19,22 @@ import {
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
|
||||
import {
|
||||
latestCompletedArtifactMutationKey,
|
||||
latestInFlightWriteFilePreview,
|
||||
latestPreparedHtmlArtifact,
|
||||
isWriteFileInFlight,
|
||||
} from "@/core/artifacts/write-file-in-flight";
|
||||
import {
|
||||
isAutoPreviewableArtifactPath,
|
||||
languageOfStreamingArtifact,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { shouldAutoOpenWritingArtifact } from "@/core/threads/writing-mode";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { filterKnowledgeWikiSources } from "@/core/llmwiki/knowledge-reference";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
@@ -35,6 +46,7 @@ import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { isWordDocumentFile } from "@/core/utils/files";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -45,6 +57,7 @@ import {
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { ReferenceBatchesContext, useThread } from "../messages/context";
|
||||
import { KnowledgeWikiPanel } from "../knowledge-wiki-panel";
|
||||
import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
@@ -83,15 +96,30 @@ const ChatBox: React.FC<{
|
||||
children: React.ReactNode;
|
||||
threadId: string;
|
||||
disableArtifacts?: boolean;
|
||||
writingMode?: boolean;
|
||||
/** Prefer and automatically reveal a specific artifact once it is available. */
|
||||
preferredArtifact?: string | null;
|
||||
/** Keep scanning until the preferred artifact appears (for delayed file persistence). */
|
||||
pollForPreferredArtifact?: boolean;
|
||||
/** Report the real artifact discovered on disk back to the page state. */
|
||||
onPreferredArtifactAvailabilityChange?: (artifact: string | null) => void;
|
||||
/** Stack the artifact below chat on small screens. */
|
||||
responsiveArtifactLayout?: boolean;
|
||||
}> = ({
|
||||
children,
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
writingMode,
|
||||
preferredArtifact = null,
|
||||
pollForPreferredArtifact = false,
|
||||
onPreferredArtifactAvailabilityChange,
|
||||
responsiveArtifactLayout = false,
|
||||
}) => {
|
||||
const {
|
||||
isMock,
|
||||
thread,
|
||||
knowledgeReferencesEnabled = false,
|
||||
knowledgeReferences,
|
||||
} = useThread();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -101,6 +129,8 @@ const ChatBox: React.FC<{
|
||||
}, [searchParams]);
|
||||
const threadIdRef = useRef(threadId);
|
||||
const openedRequestedArtifactRef = useRef<string | null>(null);
|
||||
const openedPreferredArtifactRef = useRef<string | null>(null);
|
||||
const preferredRunWasLoadingRef = useRef(false);
|
||||
const artifactsCountRef = useRef(0);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||
@@ -112,9 +142,12 @@ const ChatBox: React.FC<{
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
const queryClient = useQueryClient();
|
||||
const isMobile = useIsMobile();
|
||||
const stackArtifactPanel = responsiveArtifactLayout && isMobile;
|
||||
// 写作模式专属的产物拦截 / 沙箱打开策略只在开关开启时生效,普通模式保持
|
||||
// 原有行为(新产物自动打开 + in-flight 预览不过滤)。
|
||||
const writingModeEnabled = !!settings.context.writing_mode;
|
||||
const writingModeEnabled = writingMode ?? !!settings.context.writing_mode;
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
@@ -123,7 +156,8 @@ const ChatBox: React.FC<{
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
knowledgeReferencesEnabled;
|
||||
knowledgeReferencesEnabled ||
|
||||
!!knowledgeReferences;
|
||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||
// the existing reference panel, but must not request or write reference
|
||||
// batches through DeerFlow's backend APIs.
|
||||
@@ -140,7 +174,7 @@ const ChatBox: React.FC<{
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
(artifactDirectoryModeEnabled || !!requestedArtifact) &&
|
||||
(artifactDirectoryModeEnabled || !!requestedArtifact || !!preferredArtifact) &&
|
||||
!disableArtifacts &&
|
||||
!isMock &&
|
||||
!!threadId &&
|
||||
@@ -149,7 +183,8 @@ const ChatBox: React.FC<{
|
||||
queryKey: ["artifact-directory", threadId],
|
||||
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
||||
enabled: canScanArtifactDirectory,
|
||||
refetchInterval: thread.isLoading ? 1_500 : false,
|
||||
refetchInterval:
|
||||
thread.isLoading || pollForPreferredArtifact ? 1_500 : false,
|
||||
retry: false,
|
||||
});
|
||||
const checkpointArtifacts = useMemo(
|
||||
@@ -159,7 +194,7 @@ const ChatBox: React.FC<{
|
||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||
const availableArtifacts = useMemo(
|
||||
() => {
|
||||
if (!artifactDirectoryModeEnabled && !requestedArtifact) {
|
||||
if (!artifactDirectoryModeEnabled && !requestedArtifact && !preferredArtifact) {
|
||||
return checkpointArtifacts;
|
||||
}
|
||||
const discoveredArtifacts = (artifactListingQuery.data?.files ?? []).map(
|
||||
@@ -172,9 +207,28 @@ const ChatBox: React.FC<{
|
||||
artifactDirectoryModeEnabled,
|
||||
artifactListingQuery.data?.files,
|
||||
checkpointArtifacts,
|
||||
preferredArtifact,
|
||||
requestedArtifact,
|
||||
],
|
||||
);
|
||||
const availablePreferredArtifact = useMemo(() => {
|
||||
if (!preferredArtifact) return null;
|
||||
const preferredPath = artifactPathname(preferredArtifact);
|
||||
return (
|
||||
[...availableArtifacts]
|
||||
.reverse()
|
||||
.find((artifact) => artifactPathname(artifact) === preferredPath) ?? null
|
||||
);
|
||||
}, [availableArtifacts, preferredArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferredArtifact) return;
|
||||
onPreferredArtifactAvailabilityChange?.(availablePreferredArtifact);
|
||||
}, [
|
||||
availablePreferredArtifact,
|
||||
onPreferredArtifactAvailabilityChange,
|
||||
preferredArtifact,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canScanArtifactDirectory || thread.isLoading) return;
|
||||
@@ -199,8 +253,20 @@ const ChatBox: React.FC<{
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
openStreamedArtifact,
|
||||
setGeneratingArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
// Keep the lock even when the user closes the preview while writing.
|
||||
const selectedFileGenerating = Boolean(
|
||||
selectedArtifact && thread.isLoading &&
|
||||
isWriteFileInFlight({ messages: thread.messages ?? [] }, selectedArtifact),
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const owner = `chat:${threadId}`;
|
||||
setGeneratingArtifact(owner, selectedFileGenerating ? selectedArtifact : null);
|
||||
return () => setGeneratingArtifact(owner, null);
|
||||
}, [selectedArtifact, selectedFileGenerating, setGeneratingArtifact, threadId]);
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
@@ -239,6 +305,41 @@ const ChatBox: React.FC<{
|
||||
threadId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const completedNow =
|
||||
preferredRunWasLoadingRef.current && !thread.isLoading;
|
||||
preferredRunWasLoadingRef.current = thread.isLoading;
|
||||
if (disableArtifacts || !preferredArtifact) {
|
||||
openedPreferredArtifactRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!availablePreferredArtifact) return;
|
||||
const preferredKey = `${threadId}:${artifactPathname(availablePreferredArtifact)}`;
|
||||
if (openedPreferredArtifactRef.current === preferredKey && !completedNow) {
|
||||
return;
|
||||
}
|
||||
openedPreferredArtifactRef.current = preferredKey;
|
||||
selectArtifact(availablePreferredArtifact, true);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
// The same canonical report path may be overwritten by a later run while
|
||||
// its content query is still inside the five-minute cache window. Invalidate
|
||||
// the real selected path before the detail panel mounts/reopens so it never
|
||||
// shows the previous report after the assistant announces completion.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["artifact", availablePreferredArtifact, threadId],
|
||||
});
|
||||
}, [
|
||||
availablePreferredArtifact,
|
||||
disableArtifacts,
|
||||
preferredArtifact,
|
||||
queryClient,
|
||||
selectArtifact,
|
||||
setArtifactsOpen,
|
||||
thread.isLoading,
|
||||
threadId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches) {
|
||||
setBackendReferenceBatches([]);
|
||||
@@ -290,7 +391,7 @@ const ChatBox: React.FC<{
|
||||
),
|
||||
[backendReferenceBatches, skillDisplayMap],
|
||||
);
|
||||
const referenceBatches = useMemo(
|
||||
const resolvedReferenceBatches = useMemo(
|
||||
() => {
|
||||
if (
|
||||
shouldFetchBackendReferenceBatches &&
|
||||
@@ -306,6 +407,18 @@ const ChatBox: React.FC<{
|
||||
shouldFetchBackendReferenceBatches,
|
||||
],
|
||||
);
|
||||
const referenceBatches = useMemo(() => {
|
||||
if (!knowledgeReferences) return resolvedReferenceBatches;
|
||||
return resolvedReferenceBatches
|
||||
.map((batch) => ({
|
||||
...batch,
|
||||
sources: filterKnowledgeWikiSources(
|
||||
batch.sources,
|
||||
knowledgeReferences,
|
||||
),
|
||||
}))
|
||||
.filter((batch) => batch.sources.length > 0);
|
||||
}, [knowledgeReferences, resolvedReferenceBatches]);
|
||||
useEffect(() => {
|
||||
if (!shouldFetchBackendReferenceBatches) {
|
||||
return;
|
||||
@@ -434,13 +547,50 @@ const ChatBox: React.FC<{
|
||||
!disableArtifacts &&
|
||||
nextArtifacts.length > 0 &&
|
||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||
(hasNewArtifact || !hadArtifacts))
|
||||
) {
|
||||
const newest = nextArtifacts[nextArtifacts.length - 1]!;
|
||||
if (shouldAutoOpenWritingArtifact(newest, thread.messages, writingModeEnabled)) {
|
||||
selectArtifact(newest);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
// The preferred deliverable wins over the generic "newest artifact"
|
||||
// heuristic. Both effects can run in the same commit with a stale null
|
||||
// selectedArtifact; choosing the preferred path here prevents a sibling
|
||||
// file such as research-plan.json from overwriting report.md.
|
||||
const automaticTarget =
|
||||
availablePreferredArtifact ?? nextArtifacts[nextArtifacts.length - 1]!;
|
||||
const isGeneratedHtml =
|
||||
languageOfStreamingArtifact(automaticTarget) === "html" &&
|
||||
isAutoPreviewableArtifactPath(automaticTarget);
|
||||
if (
|
||||
shouldAutoOpenWritingArtifact(
|
||||
automaticTarget,
|
||||
thread.messages,
|
||||
writingModeEnabled,
|
||||
) &&
|
||||
(isGeneratedHtml || !selectedArtifact)
|
||||
) {
|
||||
if (isGeneratedHtml) {
|
||||
// HTML is the requested end product, so a later webpage should
|
||||
// replace a previously selected document automatically. Preserve a
|
||||
// matching in-flight write key so a user-closed live preview stays
|
||||
// closed after the tool call completes; otherwise a completed
|
||||
// tool-call id makes a later write to the same path a new preview.
|
||||
const revision = latestCompletedArtifactMutationKey(
|
||||
{ messages: thread.messages ?? [] },
|
||||
automaticTarget,
|
||||
);
|
||||
const previewKey =
|
||||
selectedArtifact?.startsWith("write-file:") &&
|
||||
artifactPathname(selectedArtifact) === artifactPathname(automaticTarget) &&
|
||||
selectedArtifactKey
|
||||
? selectedArtifactKey
|
||||
: revision ?? `artifact:${threadId}:${artifactPathname(automaticTarget)}`;
|
||||
openStreamedArtifact(
|
||||
automaticTarget,
|
||||
previewKey,
|
||||
);
|
||||
} else {
|
||||
selectArtifact(automaticTarget, true);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +609,12 @@ const ChatBox: React.FC<{
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
const firstPreviewableArtifact = thread.values.artifacts.find(
|
||||
(artifact) => !isWordDocumentFile(artifact),
|
||||
);
|
||||
if (firstPreviewableArtifact) {
|
||||
selectArtifact(firstPreviewableArtifact, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -468,17 +623,21 @@ const ChatBox: React.FC<{
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
openStreamedArtifact,
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
setArtifactsOpen,
|
||||
availableArtifacts,
|
||||
availablePreferredArtifact,
|
||||
disableArtifacts,
|
||||
thread.messages,
|
||||
writingModeEnabled,
|
||||
]);
|
||||
|
||||
// Live sandbox: open as soon as any in-flight write_file has a path or
|
||||
// the first content bytes — not only when that tool is the last CoT step.
|
||||
// Live sandbox: open when an in-flight write_file has a usable output path.
|
||||
// Pathless content stays inline in the CoT, including content-first streams.
|
||||
// Agentfx (and other agents) often emit write_file + present_files/bash in
|
||||
// the same turn; the collapsed earlier write would otherwise never auto-open.
|
||||
const streamingWritePreview = useMemo(
|
||||
@@ -497,6 +656,25 @@ const ChatBox: React.FC<{
|
||||
setSideTab("document");
|
||||
}, [openStreamedArtifact, streamingWritePreview]);
|
||||
|
||||
// page_report_template(prepare) creates the output HTML before content
|
||||
// editing starts. Open that real artifact immediately; this also covers
|
||||
// models that use str_replace or whose write_file arguments arrive at once.
|
||||
const preparedHtmlPreview = useMemo(
|
||||
() =>
|
||||
disableArtifacts || !thread.isLoading
|
||||
? null
|
||||
: latestPreparedHtmlArtifact(thread.messages ?? []),
|
||||
[disableArtifacts, thread.isLoading, thread.messages],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!preparedHtmlPreview) return;
|
||||
openStreamedArtifact(
|
||||
preparedHtmlPreview.artifactPath,
|
||||
preparedHtmlPreview.previewKey,
|
||||
);
|
||||
setSideTab("document");
|
||||
}, [openStreamedArtifact, preparedHtmlPreview]);
|
||||
|
||||
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||
useEffect(() => {
|
||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||
@@ -504,12 +682,13 @@ const ChatBox: React.FC<{
|
||||
if (
|
||||
wasEnabled ||
|
||||
!artifactDirectoryModeEnabled ||
|
||||
availableArtifacts.length === 0
|
||||
availableArtifacts.length === 0 ||
|
||||
isWordDocumentFile(availableArtifacts[availableArtifacts.length - 1]!)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!selectedArtifact) {
|
||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!, true);
|
||||
}
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
@@ -664,7 +843,7 @@ const ChatBox: React.FC<{
|
||||
<ReferenceBatchesContext.Provider value={referenceBatches}>
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
orientation={stackArtifactPanel ? "vertical" : "horizontal"}
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
>
|
||||
@@ -675,6 +854,8 @@ const ChatBox: React.FC<{
|
||||
id={`${resizableIdBase}-separator`}
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
stackArtifactPanel &&
|
||||
"h-px w-full after:inset-x-0 after:inset-y-auto after:top-1/2 after:h-4 after:w-full after:-translate-x-0 after:-translate-y-1/2 after:cursor-row-resize",
|
||||
!sidePanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
@@ -696,7 +877,9 @@ const ChatBox: React.FC<{
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="document">文档</TabsTrigger>
|
||||
<TabsTrigger value="references">参考文献</TabsTrigger>
|
||||
<TabsTrigger value="references">
|
||||
{knowledgeReferences ? "Wiki" : "参考文献"}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -712,11 +895,19 @@ const ChatBox: React.FC<{
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{artifactDocumentPanel}
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{knowledgeReferences ? (
|
||||
<KnowledgeWikiPanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
selection={knowledgeReferences}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
) : (
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : artifactPanelOpen ? (
|
||||
@@ -735,10 +926,18 @@ const ChatBox: React.FC<{
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
{knowledgeReferences ? (
|
||||
<KnowledgeWikiPanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
selection={knowledgeReferences}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
) : (
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -304,7 +304,9 @@ export function CompactModeGuide({
|
||||
setCurrent(-1);
|
||||
}, [clearPollTimer, releasePromptDialog]);
|
||||
|
||||
if (!eligible) return null;
|
||||
// TDesign 1.18 still handles resize when current=-1 and dereferences an
|
||||
// undefined step. Keep its listener mounted only during an active guide.
|
||||
if (!eligible || current < 0 || current >= stepsRef.current.length) return null;
|
||||
|
||||
return (
|
||||
<Guide
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -32,14 +33,17 @@ import {
|
||||
Share2,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Select } from "tdesign-react";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { findNanjingDbSquareId } from "@/core/system-settings/wx-type-filter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -60,6 +64,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { useAgentsPaginated, type Agent } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -75,6 +83,8 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
@@ -87,10 +97,17 @@ import {
|
||||
} from "@/core/threads/utils";
|
||||
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { usePublishSquares } from "@/core/system-settings/hooks";
|
||||
import { SENTIMENT_AGENT_ID } from "@/core/sentiment-agent/config";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
|
||||
import {
|
||||
mergeRecentWorkspaceItems,
|
||||
useRecentStrategyProjects,
|
||||
} from "@/strategy-selection/recent-projects";
|
||||
|
||||
import { agentColor } from "./agent-icon-color";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -114,30 +131,74 @@ function SectionLabel({
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{label}</span>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-200",
|
||||
collapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex w-full items-center px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex min-w-0 flex-1 items-center justify-between transition-colors hover:text-muted-foreground select-none"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate">{label}</span>
|
||||
{count != null && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform duration-200",
|
||||
collapsed && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeneralQaSidebar() {
|
||||
type CommonAgent = Pick<Agent, "id" | "name" | "description">;
|
||||
|
||||
/** wx 左栏固定的智能体类型;南京-db 广场尚未创建时筛空列表。 */
|
||||
const WX_AGENT_TYPE_MINE = "mine";
|
||||
const WX_AGENT_TYPE_DEFAULT_SQUARE = "default_square";
|
||||
const WX_AGENT_TYPE_NANJING_DB = "nanjing-db";
|
||||
const WX_AGENT_TYPE_STORAGE_KEY = "gqa-sidebar:wx-agent-type";
|
||||
const WX_AGENT_LIST_PAGE_SIZE = 50;
|
||||
|
||||
const WX_AGENT_TYPE_OPTIONS = [
|
||||
{ value: WX_AGENT_TYPE_MINE, label: "我的" },
|
||||
{ value: WX_AGENT_TYPE_DEFAULT_SQUARE, label: "默认广场" },
|
||||
{ value: WX_AGENT_TYPE_NANJING_DB, label: "南京-db" },
|
||||
] as const;
|
||||
|
||||
type WxAgentType = (typeof WX_AGENT_TYPE_OPTIONS)[number]["value"];
|
||||
|
||||
function isWxAgentType(value: string | null | undefined): value is WxAgentType {
|
||||
return WX_AGENT_TYPE_OPTIONS.some((option) => option.value === value);
|
||||
}
|
||||
|
||||
function readStoredWxAgentType(): WxAgentType {
|
||||
if (typeof window === "undefined") return WX_AGENT_TYPE_MINE;
|
||||
const stored = window.localStorage.getItem(WX_AGENT_TYPE_STORAGE_KEY);
|
||||
return isWxAgentType(stored) ? stored : WX_AGENT_TYPE_MINE;
|
||||
}
|
||||
|
||||
function resolveWxSquareId(
|
||||
agentType: WxAgentType,
|
||||
squares: Array<{ id: string; name: string }> | undefined,
|
||||
defaultSquareId: string | undefined,
|
||||
): string | undefined {
|
||||
if (agentType === WX_AGENT_TYPE_MINE) return undefined;
|
||||
if (agentType === WX_AGENT_TYPE_DEFAULT_SQUARE) return defaultSquareId;
|
||||
return findNanjingDbSquareId(squares);
|
||||
}
|
||||
|
||||
export function GeneralQaSidebar({
|
||||
wxCommonAgents,
|
||||
}: {
|
||||
/** Enables wx's shared Agent Gallery category selection. */
|
||||
wxCommonAgents?: boolean;
|
||||
} = {}) {
|
||||
const { t } = useI18n();
|
||||
const routeBase = useRouteBase();
|
||||
// 智能体图标配色分主题:深蓝主题用规范蓝-青系,其它(浅色)保持原高彩度色,互不影响。
|
||||
@@ -158,17 +219,70 @@ export function GeneralQaSidebar() {
|
||||
setAgentsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
|
||||
return !v;
|
||||
});
|
||||
});
|
||||
|
||||
const { data: publishSquares, isLoading: wxSquaresLoading } = usePublishSquares({
|
||||
enabled: !!wxCommonAgents,
|
||||
});
|
||||
const [wxAgentType, setWxAgentTypeState] = useState<WxAgentType>(
|
||||
readStoredWxAgentType,
|
||||
);
|
||||
const setWxAgentType = useCallback((next: WxAgentType) => {
|
||||
setWxAgentTypeState(next);
|
||||
window.localStorage.setItem(WX_AGENT_TYPE_STORAGE_KEY, next);
|
||||
}, []);
|
||||
const [wxSearchInput, setWxSearchInput] = useState("");
|
||||
const [wxSearch, setWxSearch] = useState("");
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setWxSearch(wxSearchInput.trim()), 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [wxSearchInput]);
|
||||
const wxSquareId = wxCommonAgents
|
||||
? resolveWxSquareId(
|
||||
wxAgentType,
|
||||
publishSquares?.squares,
|
||||
publishSquares?.default_square_id,
|
||||
)
|
||||
: undefined;
|
||||
const wxFetchEnabled =
|
||||
!!wxCommonAgents &&
|
||||
(wxAgentType === WX_AGENT_TYPE_MINE || Boolean(wxSquareId));
|
||||
const toggleChats = () =>
|
||||
setChatsCollapsed((v) => {
|
||||
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
|
||||
return !v;
|
||||
});
|
||||
|
||||
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
|
||||
const { agents: mine } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
|
||||
const { agents: square } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
|
||||
const frequent = useMemo<Agent[]>(() => {
|
||||
// ── 常用智能体取数:wx 走类型筛选;其它页面仍是置顶 → 我的 → 广场 ──
|
||||
const { agents: mine } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
enabled: !wxCommonAgents,
|
||||
});
|
||||
const { agents: square } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
enabled: !wxCommonAgents,
|
||||
});
|
||||
const {
|
||||
agents: wxAgents,
|
||||
total: wxTotal,
|
||||
isLoading: wxAgentsLoading,
|
||||
isFetching: wxAgentsFetching,
|
||||
} = useAgentsPaginated({
|
||||
scope: wxAgentType === WX_AGENT_TYPE_MINE ? "mine" : "square",
|
||||
page: 1,
|
||||
pageSize: WX_AGENT_LIST_PAGE_SIZE,
|
||||
square: wxSquareId,
|
||||
search: wxSearch || undefined,
|
||||
enabled: wxFetchEnabled,
|
||||
});
|
||||
const frequent = useMemo<CommonAgent[]>(() => {
|
||||
if (wxCommonAgents) {
|
||||
return wxFetchEnabled ? wxAgents : [];
|
||||
}
|
||||
const out: Agent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (agent: Agent | undefined) => {
|
||||
@@ -180,7 +294,15 @@ export function GeneralQaSidebar() {
|
||||
mine.forEach(add);
|
||||
square.forEach(add);
|
||||
return out;
|
||||
}, [mine, square]);
|
||||
}, [mine, square, wxAgents, wxCommonAgents, wxFetchEnabled]);
|
||||
const wxEmptyHint =
|
||||
wxAgentType === WX_AGENT_TYPE_NANJING_DB && !wxSquareId
|
||||
? "该类型暂无智能体"
|
||||
: wxSearch
|
||||
? "未找到匹配的智能体"
|
||||
: wxAgentType === WX_AGENT_TYPE_MINE
|
||||
? "暂无智能体"
|
||||
: "该广场暂无智能体";
|
||||
|
||||
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
|
||||
const searchParams = useSearchParams();
|
||||
@@ -193,6 +315,12 @@ export function GeneralQaSidebar() {
|
||||
`${routeBase}/agents`,
|
||||
currentSearch,
|
||||
);
|
||||
const moreSkillsHref = buildFullChatNavigationPath(
|
||||
`${routeBase}/skills`,
|
||||
currentSearch,
|
||||
);
|
||||
const wxAgentsPageActive = pathname === `${routeBase}/agents`;
|
||||
const wxSkillsPageActive = pathname === `${routeBase}/skills`;
|
||||
const moreChatsHref = buildFullChatNavigationPath(chatsBasePath, currentSearch);
|
||||
|
||||
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
|
||||
@@ -200,21 +328,31 @@ export function GeneralQaSidebar() {
|
||||
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
|
||||
const handleSelectAgent = useCallback(
|
||||
(agentId: string) => {
|
||||
if (agentId === SENTIMENT_AGENT_ID) {
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats/new`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigate(
|
||||
buildFullChatNavigationPath(
|
||||
routeBase === "/page/workspace"
|
||||
routeBase === "/page/workspace" || wxCommonAgents
|
||||
? `${newChatPath}?agent=${encodeURIComponent(agentId)}`
|
||||
: `${routeBase}/agents/${encodeURIComponent(agentId)}/chats/new`,
|
||||
currentSearch,
|
||||
),
|
||||
);
|
||||
},
|
||||
[currentSearch, navigate, newChatPath, routeBase],
|
||||
[currentSearch, navigate, newChatPath, routeBase, wxCommonAgents],
|
||||
);
|
||||
|
||||
// ── 最近的对话取数 + 搜索 ──
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const { mutate: renameThread } = useRenameThread();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
@@ -241,17 +379,28 @@ export function GeneralQaSidebar() {
|
||||
sortThreadsPinnedFirst(threads.filter((thread) => !isHiddenFromRecentChats(thread))),
|
||||
[threads],
|
||||
);
|
||||
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
||||
const recentItems = useMemo(
|
||||
() =>
|
||||
mergeRecentWorkspaceItems(visibleThreads, recentStrategyProjects),
|
||||
[recentStrategyProjects, visibleThreads],
|
||||
);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const filteredRecentItems = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const list = q
|
||||
? visibleThreads.filter((thread) =>
|
||||
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
|
||||
? recentItems.filter((item) =>
|
||||
(item.kind === "thread"
|
||||
? titleOfThread(item.thread, t.pages.newChat)
|
||||
: item.project.title
|
||||
)
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
)
|
||||
: visibleThreads;
|
||||
: recentItems;
|
||||
// 未搜索时与旧版一致:最多展示 10 条
|
||||
return q ? list : list.slice(0, 10);
|
||||
}, [visibleThreads, query, t.pages.newChat]);
|
||||
}, [query, recentItems, t.pages.newChat]);
|
||||
|
||||
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
|
||||
@@ -354,17 +503,88 @@ export function GeneralQaSidebar() {
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||
{/* ── 常用智能体 ── */}
|
||||
{frequent.length > 0 && (
|
||||
{(wxCommonAgents || frequent.length > 0) && (
|
||||
<div className="pt-2">
|
||||
<SectionLabel
|
||||
label="常用智能体"
|
||||
count={wxCommonAgents ? frequent.length : undefined}
|
||||
collapsed={agentsCollapsed}
|
||||
onToggle={toggleAgents}
|
||||
/>
|
||||
{!agentsCollapsed && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
{wxCommonAgents && (
|
||||
<div className="space-y-2 px-1 pb-1">
|
||||
<Select
|
||||
size="medium"
|
||||
value={wxAgentType}
|
||||
placeholder="智能体类型"
|
||||
onChange={(value) => {
|
||||
if (isWxAgentType(String(value))) {
|
||||
setWxAgentType(String(value) as WxAgentType);
|
||||
}
|
||||
}}
|
||||
className="w-full [&_.t-input]:!h-9 [&_.t-input]:!rounded-lg [&_.t-input]:!border-border [&_.t-input]:!bg-card [&_.t-input]:!px-2.5 [&_.t-input]:!text-[13px]"
|
||||
popupProps={{
|
||||
overlayClassName: "z-[80]",
|
||||
attach: () => document.body,
|
||||
}}
|
||||
>
|
||||
{WX_AGENT_TYPE_OPTIONS.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
|
||||
<Input
|
||||
value={wxSearchInput}
|
||||
onChange={(e) => setWxSearchInput(e.target.value)}
|
||||
placeholder="搜索智能体"
|
||||
className="h-8 rounded-lg border-border bg-card pl-8 pr-8 text-[12px]"
|
||||
aria-label="搜索智能体"
|
||||
/>
|
||||
{wxSearchInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWxSearchInput("")}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
title="清除"
|
||||
aria-label="清除搜索"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{wxCommonAgents &&
|
||||
(wxAgentsLoading ||
|
||||
(wxAgentType !== WX_AGENT_TYPE_MINE && wxSquaresLoading)) &&
|
||||
frequent.length === 0 ? (
|
||||
<div className="flex h-20 items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
加载中…
|
||||
</div>
|
||||
) : frequent.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground/70">
|
||||
{wxCommonAgents ? wxEmptyHint : null}
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[240px] flex-col gap-1 overflow-y-auto overscroll-contain pr-0.5",
|
||||
wxCommonAgents && wxAgentsFetching && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{frequent.map((agent) => {
|
||||
const isActive = activeAgentId === agent.id;
|
||||
const isActive =
|
||||
activeAgentId === agent.id ||
|
||||
(agent.id === SENTIMENT_AGENT_ID &&
|
||||
pathname.startsWith(
|
||||
`${routeBase}/agents/${SENTIMENT_AGENT_ID}/chats`,
|
||||
));
|
||||
const { from, to, foreground } = agentColor(agent.id, theme);
|
||||
return (
|
||||
<button
|
||||
@@ -437,13 +657,50 @@ export function GeneralQaSidebar() {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href={moreAgentsHref}
|
||||
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<span>{t.common.more}</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{wxCommonAgents ? (
|
||||
<div className="grid grid-cols-2 gap-1.5 px-1 pt-0.5">
|
||||
<Link
|
||||
href={moreAgentsHref}
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-lg text-[12px] font-medium transition-colors",
|
||||
wxAgentsPageActive
|
||||
? isDark
|
||||
? "bg-[#1a5dad] text-white"
|
||||
: "bg-primary text-primary-foreground"
|
||||
: isDark
|
||||
? "bg-[#0c3a6e] text-[#d6eaff] hover:bg-[#124a86]"
|
||||
: "bg-accent text-foreground hover:bg-accent/80",
|
||||
)}
|
||||
>
|
||||
智能体
|
||||
</Link>
|
||||
<Link
|
||||
href={moreSkillsHref}
|
||||
className={cn(
|
||||
"flex h-8 items-center justify-center rounded-lg text-[12px] font-medium transition-colors",
|
||||
wxSkillsPageActive
|
||||
? isDark
|
||||
? "bg-[#1a5dad] text-white"
|
||||
: "bg-primary text-primary-foreground"
|
||||
: isDark
|
||||
? "bg-[#0c3a6e] text-[#d6eaff] hover:bg-[#124a86]"
|
||||
: "bg-accent text-foreground hover:bg-accent/80",
|
||||
)}
|
||||
>
|
||||
技能
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={moreAgentsHref}
|
||||
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<span>{t.common.more}</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -453,7 +710,7 @@ export function GeneralQaSidebar() {
|
||||
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
|
||||
<SectionLabel
|
||||
label={t.sidebar.recentChats}
|
||||
count={visibleThreads.length}
|
||||
count={recentItems.length}
|
||||
collapsed={chatsCollapsed}
|
||||
onToggle={toggleChats}
|
||||
/>
|
||||
@@ -471,29 +728,75 @@ export function GeneralQaSidebar() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{filteredThreads.length === 0 ? (
|
||||
{filteredRecentItems.length === 0 ? (
|
||||
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
|
||||
{query ? "没有匹配的会话" : t.chats.noChats}
|
||||
</div>
|
||||
) : (
|
||||
filteredThreads.map((thread) => {
|
||||
filteredRecentItems.map((item) => {
|
||||
if (item.kind === "strategy-project") {
|
||||
const { project } = item;
|
||||
const href = strategyProjectWorkbenchPath(
|
||||
"/page/strategy/qa/projects",
|
||||
project.id,
|
||||
);
|
||||
const isActive = href === pathname;
|
||||
return (
|
||||
<div
|
||||
key={`strategy-project:${project.id}`}
|
||||
className="group/item relative flex items-center"
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"gq-history-item relative flex min-h-12 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] transition-colors",
|
||||
isActive
|
||||
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<MyProjectThreadIcon type="strategy" />
|
||||
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
|
||||
<span className="w-full truncate">
|
||||
{desensitize(project.title)}
|
||||
</span>
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4 max-w-[calc(100%_-_1rem)]"
|
||||
projectName={project.title}
|
||||
type="strategy"
|
||||
/>
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { thread } = item;
|
||||
const href = buildFullChatNavigationPath(
|
||||
pathForThread(thread),
|
||||
currentSearch,
|
||||
);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectInfo = myProjectConversationInfoOfThread(
|
||||
thread,
|
||||
projectThreadNames,
|
||||
);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
<div
|
||||
key={thread.thread_id}
|
||||
key={`thread:${thread.thread_id}`}
|
||||
className="group/item relative flex items-center"
|
||||
>
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"gq-history-item relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
|
||||
projectInfo && "h-auto min-h-12 py-1.5",
|
||||
isActive
|
||||
? "gq-history-item--active border border-primary/40 bg-primary/5 font-medium text-primary"
|
||||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||||
@@ -507,6 +810,8 @@ export function GeneralQaSidebar() {
|
||||
)}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectInfo ? (
|
||||
<MyProjectThreadIcon type={projectInfo.type} />
|
||||
) : (
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
@@ -518,8 +823,22 @@ export function GeneralQaSidebar() {
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
projectInfo && "flex flex-col items-start gap-0.5",
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectInfo && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-4 max-w-[calc(100%_-_1rem)]"
|
||||
projectName={projectInfo.projectName}
|
||||
type={projectInfo.type}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import { hasPromptInputPayload } from "./input-box-submit.ts";
|
||||
|
||||
test("allows attachment-only prompt submissions", () => {
|
||||
assert.equal(hasPromptInputPayload({ text: "", files: [{}] }), true);
|
||||
});
|
||||
|
||||
test("rejects submissions with neither text nor attachments", () => {
|
||||
assert.equal(hasPromptInputPayload({ text: "", files: [] }), false);
|
||||
assert.equal(hasPromptInputPayload({ text: " \n", files: [] }), false);
|
||||
});
|
||||
|
||||
test("allows text-only prompt submissions", () => {
|
||||
assert.equal(hasPromptInputPayload({ text: "总结一下", files: [] }), true);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export type PromptInputPayload = {
|
||||
text: string;
|
||||
files: readonly unknown[];
|
||||
};
|
||||
|
||||
/** A prompt may contain text, attachments, or both. */
|
||||
export function hasPromptInputPayload(message: PromptInputPayload): boolean {
|
||||
return message.text.trim().length > 0 || message.files.length > 0;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
BrainCircuitIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
FolderTreeIcon,
|
||||
@@ -63,11 +64,22 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
isDocumentRewriteActiveStage,
|
||||
useDocumentRewritePresentation,
|
||||
} from "@/core/document-rewrite/context";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import {
|
||||
REASONING_EFFORTS,
|
||||
resolveModelReasoningEffort,
|
||||
type ReasoningEffort,
|
||||
type StoredReasoningEffort,
|
||||
} from "@/core/models/reasoning-effort";
|
||||
import type { Model } from "@/core/models/types";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import type { KnowledgeRetrievalMode } from "@/core/threads/knowledge-retrieval-mode";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -101,26 +113,12 @@ import {
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { resolveInputMode, type InputMode } from "./input-mode";
|
||||
import { hasPromptInputPayload } from "./input-box-submit";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
|
||||
function getResolvedMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
): InputMode {
|
||||
if (!supportsThinking && mode !== "flash") {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
|
||||
export function InputBox({
|
||||
className,
|
||||
disabled,
|
||||
@@ -132,18 +130,20 @@ export function InputBox({
|
||||
threadId,
|
||||
initialValue,
|
||||
onContextChange,
|
||||
onFollowupsVisibilityChange,
|
||||
onFollowupsPresenceChange,
|
||||
followupsDisplayAllowed = true,
|
||||
onSubmit,
|
||||
onStop,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
knowledgeSelector,
|
||||
knowledgeRetrieval,
|
||||
knowledgeSuggestedQuestions = [],
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
compact,
|
||||
hideContextTools,
|
||||
hideMemoryTool = true,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -151,23 +151,17 @@ export function InputBox({
|
||||
/** 紧凑模式(正常 App)新对话落地态:去掉旋转彩边、用 CM 参考图的白卡输入框,
|
||||
* 并在推荐问题上方加「试试这些高频业务场景」标题。仅落地态生效,默认 false。 */
|
||||
compact?: boolean;
|
||||
/** 隐藏输入框工具条上的「写作 / 记忆 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
/** 隐藏输入框工具条上的「写作 / 参考文献」上下文开关按钮(任务深链工作区等
|
||||
* 受控场景用;文件/模型等其他按钮不受影响)。 */
|
||||
hideContextTools?: boolean;
|
||||
/**
|
||||
* 仅控制「记忆」开关的可见性。产品当前全局隐藏,传入 false 即可无损恢复。
|
||||
* 写作和参考文献开关不受影响。
|
||||
*/
|
||||
hideMemoryTool?: boolean;
|
||||
disabled?: boolean;
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
reasoning_effort?: StoredReasoningEffort;
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -182,12 +176,18 @@ export function InputBox({
|
||||
"thread_id" | "is_plan_mode" | "thinking_enabled" | "subagent_enabled"
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
reasoning_effort?: StoredReasoningEffort;
|
||||
writing_mode?: boolean;
|
||||
memory_injection_enabled?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
/**
|
||||
* Whether generated follow-up questions may be rendered. Hosts with a
|
||||
* scrollable transcript disable this while the user is reviewing history;
|
||||
* the generated questions are retained and reappear on returning to bottom.
|
||||
*/
|
||||
followupsDisplayAllowed?: boolean;
|
||||
/** Reports whether follow-ups need layout space, even while scroll-hidden. */
|
||||
onFollowupsPresenceChange?: (present: boolean) => void;
|
||||
onSubmit?: (message: PromptInputMessage) => void;
|
||||
onStop?: () => void;
|
||||
/**
|
||||
@@ -211,6 +211,17 @@ export function InputBox({
|
||||
};
|
||||
/** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */
|
||||
knowledgeSelector?: ReactNode;
|
||||
/** Per-thread ordinary-knowledge retrieval mode. Omit when unavailable. */
|
||||
knowledgeRetrieval?: {
|
||||
mode: KnowledgeRetrievalMode;
|
||||
smartAvailable: boolean;
|
||||
smartAvailabilityReason: string;
|
||||
/** The administrator policy used only when a new conversation is created. */
|
||||
smartIsDefault: boolean;
|
||||
onModeChange: (mode: KnowledgeRetrievalMode) => void;
|
||||
};
|
||||
/** High-value starter questions derived from the currently selected spaces. */
|
||||
knowledgeSuggestedQuestions?: string[];
|
||||
/** Page-scoped model choices implemented outside the DeerFlow model API. */
|
||||
extraModels?: Model[];
|
||||
}) {
|
||||
@@ -228,11 +239,25 @@ export function InputBox({
|
||||
return singleRoundRagActive ? extraModels : models;
|
||||
}, [extraModels, models, singleRoundRagActive]);
|
||||
const { thread, isMock } = useThread();
|
||||
const rewritePresentation = useDocumentRewritePresentation();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const documentRewriteActive = useMemo(
|
||||
() =>
|
||||
rewritePresentation?.forThread(threadId).some((item) =>
|
||||
isDocumentRewriteActiveStage(item.stage),
|
||||
) ?? false,
|
||||
[rewritePresentation, threadId],
|
||||
);
|
||||
// Keep normal chat generation authoritative when both states overlap. In
|
||||
// the usual rewrite-only case this gives the composer the same square stop
|
||||
// affordance as a normal streamed reply.
|
||||
const composerStatus: ChatStatus = documentRewriteActive
|
||||
? "streaming"
|
||||
: status;
|
||||
|
||||
const [followups, setFollowups] = useState<string[]>([]);
|
||||
const [followupsHidden, setFollowupsHidden] = useState(false);
|
||||
@@ -247,8 +272,10 @@ export function InputBox({
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const followupsEnabled = userPreferences.followup_suggestions_enabled;
|
||||
const showAdminRecommendedQuestions =
|
||||
!hideAdminRecommendedQuestions &&
|
||||
knowledgeSuggestedQuestions.length === 0 &&
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
@@ -264,12 +291,20 @@ export function InputBox({
|
||||
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||
const supportsReasoningEffort =
|
||||
fallbackModel.supports_reasoning_effort ?? false;
|
||||
const nextModelName = fallbackModel.name;
|
||||
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
||||
const nextMode = resolveInputMode(context.mode, supportsThinking);
|
||||
const nextReasoningEffort = resolveModelReasoningEffort(
|
||||
supportsReasoningEffort,
|
||||
context.reasoning_effort,
|
||||
context.model_name !== nextModelName,
|
||||
);
|
||||
|
||||
if (
|
||||
context.model_name === nextModelName &&
|
||||
context.mode === nextMode &&
|
||||
context.reasoning_effort === nextReasoningEffort &&
|
||||
context.execution_profile === fallbackModel.execution_profile
|
||||
) {
|
||||
return;
|
||||
@@ -279,6 +314,7 @@ export function InputBox({
|
||||
...context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
reasoning_effort: nextReasoningEffort,
|
||||
execution_profile: fallbackModel.execution_profile,
|
||||
});
|
||||
}, [context, modelOptions, multiRoundRagActive, onContextChange]);
|
||||
@@ -303,6 +339,31 @@ export function InputBox({
|
||||
[selectedModel],
|
||||
);
|
||||
|
||||
const selectedReasoningEffort = useMemo(
|
||||
() =>
|
||||
resolveModelReasoningEffort(
|
||||
supportReasoningEffort,
|
||||
context.reasoning_effort,
|
||||
),
|
||||
[context.reasoning_effort, supportReasoningEffort],
|
||||
);
|
||||
|
||||
const reasoningEffortIndex = Math.max(
|
||||
0,
|
||||
REASONING_EFFORTS.indexOf(selectedReasoningEffort ?? "xhigh"),
|
||||
);
|
||||
const reasoningEffortThumbLeft =
|
||||
reasoningEffortIndex === 0
|
||||
? "0.5rem"
|
||||
: reasoningEffortIndex === 1
|
||||
? "50%"
|
||||
: "calc(100% - 0.5rem)";
|
||||
const reasoningEffortLabel =
|
||||
selectedReasoningEffort === "low"
|
||||
? t.inputBox.reasoningEffortLow
|
||||
: selectedReasoningEffort === "medium"
|
||||
? t.inputBox.reasoningEffortMedium
|
||||
: t.inputBox.reasoningEffortXHigh;
|
||||
const handleModelSelect = useCallback(
|
||||
(model_name: string) => {
|
||||
const model = modelOptions.find((m) => m.name === model_name);
|
||||
@@ -312,8 +373,12 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name,
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
mode: resolveInputMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: resolveModelReasoningEffort(
|
||||
model.supports_reasoning_effort ?? false,
|
||||
context.reasoning_effort,
|
||||
model_name !== context.model_name,
|
||||
),
|
||||
execution_profile: model.execution_profile,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
@@ -328,22 +393,24 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
mode,
|
||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||
),
|
||||
reasoning_effort:
|
||||
mode === "ultra"
|
||||
? "high"
|
||||
: mode === "pro"
|
||||
? "medium"
|
||||
: mode === "thinking"
|
||||
? "low"
|
||||
: "minimal",
|
||||
reasoning_effort: resolveModelReasoningEffort(
|
||||
ordinaryModel?.supports_reasoning_effort ?? supportReasoningEffort,
|
||||
context.reasoning_effort,
|
||||
),
|
||||
execution_profile: undefined,
|
||||
});
|
||||
},
|
||||
[onContextChange, context, models, supportThinking],
|
||||
[
|
||||
onContextChange,
|
||||
context,
|
||||
models,
|
||||
supportThinking,
|
||||
supportReasoningEffort,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSingleRoundRagSelect = useCallback(() => {
|
||||
@@ -357,7 +424,7 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ragModel.name,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
context.mode,
|
||||
ragModel.supports_thinking ?? false,
|
||||
),
|
||||
@@ -373,7 +440,7 @@ export function InputBox({
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const handleReasoningEffortSelect = useCallback(
|
||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||
(effort: ReasoningEffort) => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
reasoning_effort: effort,
|
||||
@@ -397,16 +464,13 @@ export function InputBox({
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
memory_injection_enabled: !memoryInjectionEnabled,
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const smartKnowledgeRetrievalActive =
|
||||
knowledgeRetrieval?.mode === "smart_reasoning";
|
||||
const ragModeLocked =
|
||||
!smartKnowledgeRetrievalActive && isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled =
|
||||
!smartKnowledgeRetrievalActive &&
|
||||
(localSettings.display.ragModeEnabled || ragModeLocked);
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
if (ragModeLocked) {
|
||||
return;
|
||||
@@ -424,11 +488,15 @@ export function InputBox({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
onStop?.();
|
||||
return;
|
||||
if (composerStatus === "streaming") {
|
||||
toast.info("正在生成中,请等待当前回复完成后再发送。", {
|
||||
description: "已保留您输入的内容。",
|
||||
});
|
||||
// Reject rather than resolve: PromptInput treats a resolved submit as
|
||||
// accepted and clears its draft. The current message has not been sent.
|
||||
throw new Error("Cannot submit while a response is streaming.");
|
||||
}
|
||||
if (!message.text) {
|
||||
if (!hasPromptInputPayload(message)) {
|
||||
return;
|
||||
}
|
||||
setFollowups([]);
|
||||
@@ -440,14 +508,19 @@ export function InputBox({
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
// effect has flushed thread settings to storage/state.
|
||||
if (resolvedModelName && context.model_name !== resolvedModelName) {
|
||||
if (
|
||||
resolvedModelName &&
|
||||
(context.model_name !== resolvedModelName ||
|
||||
context.reasoning_effort !== selectedReasoningEffort)
|
||||
) {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: getResolvedMode(
|
||||
mode: resolveInputMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
reasoning_effort: selectedReasoningEffort,
|
||||
execution_profile: selectedModel?.execution_profile,
|
||||
});
|
||||
setTimeout(() => onSubmit?.(message), 0);
|
||||
@@ -460,16 +533,30 @@ export function InputBox({
|
||||
context,
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
composerStatus,
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
selectedModel?.execution_profile,
|
||||
selectedReasoningEffort,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
|
||||
const handleStopCurrentGeneration = useCallback(() => {
|
||||
if (status === "streaming") {
|
||||
onStop?.();
|
||||
return;
|
||||
}
|
||||
if (!documentRewriteActive || !rewritePresentation) return;
|
||||
void rewritePresentation.cancelActiveForThread(threadId).then((result) => {
|
||||
if (!result.cancelled) {
|
||||
toast.error(result.error ?? "停止全文改写失败,请稍后重试。");
|
||||
}
|
||||
});
|
||||
}, [documentRewriteActive, onStop, rewritePresentation, status, threadId]);
|
||||
|
||||
const requestFormSubmit = useCallback(() => {
|
||||
const form = promptRootRef.current?.querySelector("form");
|
||||
form?.requestSubmit();
|
||||
@@ -477,7 +564,7 @@ export function InputBox({
|
||||
|
||||
const handleFollowupClick = useCallback(
|
||||
(suggestion: string) => {
|
||||
if (status === "streaming") {
|
||||
if (composerStatus === "streaming") {
|
||||
return;
|
||||
}
|
||||
const current = (textInput.value ?? "").trim();
|
||||
@@ -490,7 +577,7 @@ export function InputBox({
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
},
|
||||
[requestFormSubmit, status, textInput],
|
||||
[composerStatus, requestFormSubmit, textInput],
|
||||
);
|
||||
|
||||
const confirmReplaceAndSend = useCallback(() => {
|
||||
@@ -521,32 +608,43 @@ export function InputBox({
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||
|
||||
const showFollowups =
|
||||
const followupsPresent =
|
||||
followupsEnabled &&
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!selectedSingleRoundRag &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0);
|
||||
const showFollowups = followupsDisplayAllowed && followupsPresent;
|
||||
|
||||
const followupsVisibilityChangeRef = useRef(onFollowupsVisibilityChange);
|
||||
const followupsPresenceChangeRef = useRef(onFollowupsPresenceChange);
|
||||
|
||||
useEffect(() => {
|
||||
followupsVisibilityChangeRef.current = onFollowupsVisibilityChange;
|
||||
}, [onFollowupsVisibilityChange]);
|
||||
followupsPresenceChangeRef.current = onFollowupsPresenceChange;
|
||||
}, [onFollowupsPresenceChange]);
|
||||
|
||||
useEffect(() => {
|
||||
followupsVisibilityChangeRef.current?.(showFollowups);
|
||||
}, [showFollowups]);
|
||||
followupsPresenceChangeRef.current?.(followupsPresent);
|
||||
}, [followupsPresent]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => followupsVisibilityChangeRef.current?.(false);
|
||||
return () => followupsPresenceChangeRef.current?.(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (followupsEnabled) return;
|
||||
setFollowups([]);
|
||||
setFollowupsLoading(false);
|
||||
setFollowupsHidden(true);
|
||||
setConfirmOpen(false);
|
||||
setPendingSuggestion(null);
|
||||
}, [followupsEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const streaming = status === "streaming";
|
||||
const wasStreaming = wasStreamingRef.current;
|
||||
wasStreamingRef.current = streaming;
|
||||
if (!wasStreaming || streaming) {
|
||||
if (!followupsEnabled || !wasStreaming || streaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -598,6 +696,7 @@ export function InputBox({
|
||||
return (await res.json()) as { suggestions?: string[] };
|
||||
})
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const suggestions = (data.suggestions ?? [])
|
||||
.map((s) => (typeof s === "string" ? s.trim() : ""))
|
||||
.filter((s) => s.length > 0)
|
||||
@@ -605,14 +704,19 @@ export function InputBox({
|
||||
setFollowups(suggestions);
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFollowups([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (controller.signal.aborted) return;
|
||||
setFollowupsLoading(false);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||
return () => {
|
||||
controller.abort();
|
||||
setFollowupsLoading(false);
|
||||
};
|
||||
}, [context.model_name, disabled, followupsEnabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
@@ -622,6 +726,28 @@ export function InputBox({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && knowledgeSuggestedQuestions.length > 0 && (
|
||||
<div className="px-4 mb-[10px]">
|
||||
<div className="mb-2 text-center text-xs text-muted-foreground">基于已选知识空间,试试这样问</div>
|
||||
<div className="flex flex-wrap justify-center gap-2.5">
|
||||
{knowledgeSuggestedQuestions.slice(0, 4).map((question) => (
|
||||
<button
|
||||
key={question}
|
||||
type="button"
|
||||
className="max-w-full rounded-full border border-primary/20 bg-primary/5 px-4 py-2 text-left text-sm text-foreground transition-colors hover:bg-primary/10"
|
||||
onClick={() => {
|
||||
textInput.setInput(question);
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
@@ -733,12 +859,12 @@ export function InputBox({
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter
|
||||
className={cn(
|
||||
"flex items-center border-t-0 px-2 pt-1 pb-1.5",
|
||||
"flex flex-wrap items-center gap-y-2 border-t-0 px-2 pt-1 pb-1.5",
|
||||
compact && "gap-2 px-3 pt-1.5 pb-3",
|
||||
)}
|
||||
>
|
||||
<PromptInputTools
|
||||
className={cn("gap-1.5", compact && "flex-1 flex-wrap gap-2")}
|
||||
className={cn("min-w-0 flex-1 flex-wrap gap-1.5", compact && "gap-2")}
|
||||
>
|
||||
{/* TODO: Add more connectors here
|
||||
<PromptInputActionMenu>
|
||||
@@ -813,62 +939,42 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && !hideContextTools && !hideMemoryTool && <Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
: "记忆注入已关闭:本次对话不注入记忆"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
memoryInjectionEnabled
|
||||
? "border border-blue-300 bg-blue-50 text-blue-700 ring-1 ring-blue-300/35 hover:bg-blue-100 dark:border-cyan-300/70 dark:bg-cyan-400/20 dark:text-cyan-50 dark:ring-cyan-300/35 dark:shadow-[0_0_0_1px_rgba(34,211,238,0.18),0_0_18px_rgba(34,211,238,0.18)] dark:hover:bg-cyan-400/25"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleMemoryInjectionToggle}
|
||||
>
|
||||
{memoryInjectionEnabled && (
|
||||
<CheckIcon className="size-3 text-blue-700 dark:text-cyan-50" />
|
||||
)}
|
||||
<BrainCircuitIcon className="size-3" />
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{referenceModeEntryEnabled && !frontendRagActive && !hideContextTools && <Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:点击开启"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={ragModeEnabled}
|
||||
disabled={ragModeLocked || disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||
ragModeEnabled
|
||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleRagModeToggle}
|
||||
>
|
||||
{ragModeEnabled && (
|
||||
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
|
||||
)}
|
||||
<BookMarkedIcon className="size-3" />
|
||||
<span className="text-xs font-normal">参考文献</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{referenceModeEntryEnabled &&
|
||||
!frontendRagActive &&
|
||||
!smartKnowledgeRetrievalActive &&
|
||||
!hideContextTools && (
|
||||
<Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:点击开启"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={ragModeEnabled}
|
||||
disabled={ragModeLocked || disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact &&
|
||||
"rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||
ragModeEnabled
|
||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleRagModeToggle}
|
||||
>
|
||||
{ragModeEnabled && (
|
||||
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
|
||||
)}
|
||||
<BookMarkedIcon className="size-3" />
|
||||
<span className="text-xs font-normal">参考文献</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!frontendRagActive && <Tooltip
|
||||
content={
|
||||
contextCompressionEnabled
|
||||
@@ -1161,124 +1267,63 @@ export function InputBox({
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
{!frontendRagActive && supportReasoningEffort && context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger className={cn("gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", compact && "rounded-lg border border-border px-2.5! py-1! text-[13px]")}>
|
||||
<div className="text-xs font-normal">
|
||||
{t.inputBox.reasoningEffort}:
|
||||
{context.reasoning_effort === "minimal" &&
|
||||
" " + t.inputBox.reasoningEffortMinimal}
|
||||
{context.reasoning_effort === "low" &&
|
||||
" " + t.inputBox.reasoningEffortLow}
|
||||
{context.reasoning_effort === "medium" &&
|
||||
" " + t.inputBox.reasoningEffortMedium}
|
||||
{context.reasoning_effort === "high" &&
|
||||
" " + t.inputBox.reasoningEffortHigh}
|
||||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
<PromptInputActionMenuContent className="w-70">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-muted-foreground text-xs">
|
||||
{t.inputBox.reasoningEffort}
|
||||
</DropdownMenuLabel>
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "minimal"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("minimal")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortMinimal}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortMinimalDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "minimal" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "low"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("low")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortLow}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortLowDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "low" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "medium" ||
|
||||
!context.reasoning_effort
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("medium")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortMedium}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortMediumDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "medium" ||
|
||||
!context.reasoning_effort ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.reasoning_effort === "high"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleReasoningEffortSelect("high")}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
{t.inputBox.reasoningEffortHigh}
|
||||
</div>
|
||||
<div className="pl-2 text-xs">
|
||||
{t.inputBox.reasoningEffortHighDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.reasoning_effort === "high" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
</PromptInputActionMenu>
|
||||
</DropdownMenuGroup>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
{!frontendRagActive &&
|
||||
supportReasoningEffort &&
|
||||
context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger
|
||||
aria-label={`${t.inputBox.reasoningEffortSelect}: ${reasoningEffortLabel}`}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
compact &&
|
||||
"rounded-lg border border-border px-2.5! py-1! text-[13px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<SparklesIcon className="size-3" />
|
||||
<span className="text-foreground">
|
||||
{reasoningEffortLabel}
|
||||
</span>
|
||||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
<PromptInputActionMenuContent className="w-56 p-3">
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.inputBox.reasoningEffortSelect}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-violet-600 dark:text-violet-300">
|
||||
{reasoningEffortLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 px-1">
|
||||
<div className="relative h-9 w-full">
|
||||
<input
|
||||
type="range"
|
||||
aria-label={`${t.inputBox.reasoningEffortSelect}: ${selectedModel?.display_name || selectedModel?.name || ""}`}
|
||||
aria-valuetext={reasoningEffortLabel}
|
||||
className="peer absolute inset-x-0 -inset-y-1 z-10 h-auto w-full cursor-grab touch-none opacity-0 active:cursor-grabbing"
|
||||
value={reasoningEffortIndex}
|
||||
min={0}
|
||||
max={2}
|
||||
step={1}
|
||||
onChange={(event) => {
|
||||
const effort =
|
||||
REASONING_EFFORTS[Number(event.currentTarget.value)];
|
||||
if (effort) handleReasoningEffortSelect(effort);
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-2 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-sky-400 via-violet-500 to-fuchsia-500 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500/70 peer-focus-visible:ring-offset-2" />
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-violet-600 shadow-md peer-active:scale-110 dark:border-slate-800"
|
||||
style={{ left: reasoningEffortThumbLeft }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
<PromptInputTools className={cn("ml-auto shrink-0 gap-1.5", compact && "gap-2")}>
|
||||
{!frontendRagActive && agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
@@ -1288,6 +1333,100 @@ export function InputBox({
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && knowledgeSelector}
|
||||
{!frontendRagActive && knowledgeRetrieval && (
|
||||
<DropdownMenu>
|
||||
<Tooltip
|
||||
content={
|
||||
knowledgeRetrieval.mode === "smart_reasoning"
|
||||
? "智能检索已开启:直接提问即可,系统会检索原文分片并沿 Wiki 线索扩展背景资料"
|
||||
: knowledgeRetrieval.smartAvailabilityReason
|
||||
}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={`普通知识库检索模式:${
|
||||
knowledgeRetrieval.mode === "smart_reasoning"
|
||||
? "智能检索"
|
||||
: "经典检索"
|
||||
}`}
|
||||
aria-haspopup="menu"
|
||||
className={cn(
|
||||
"rounded-full px-3 shadow-none transition-colors",
|
||||
knowledgeRetrieval.mode === "smart_reasoning"
|
||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: "border border-transparent bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<BrainCircuitIcon className="size-3.5" />
|
||||
<span className="max-w-[160px] truncate text-xs font-normal">
|
||||
{knowledgeRetrieval.mode === "smart_reasoning" ? "智能检索" : "经典检索"}
|
||||
</span>
|
||||
</PromptInputButton>
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="w-80 rounded-xl p-1.5"
|
||||
>
|
||||
<DropdownMenuLabel className="px-2 pb-1 text-xs text-muted-foreground">
|
||||
普通知识库检索模式
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
className="min-h-16 items-start rounded-lg py-2"
|
||||
disabled={!knowledgeRetrieval.smartAvailable}
|
||||
onSelect={() =>
|
||||
knowledgeRetrieval.onModeChange("smart_reasoning")
|
||||
}
|
||||
>
|
||||
<BrainCircuitIcon className="mt-0.5 size-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">
|
||||
智能检索
|
||||
{knowledgeRetrieval.smartIsDefault ? "(新对话默认)" : ""}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
先检索原文分片,再按命中线索查阅并扩展 Wiki;资料仅作为回答背景
|
||||
</div>
|
||||
</div>
|
||||
{knowledgeRetrieval.mode === "smart_reasoning" && (
|
||||
<CheckIcon className="mt-0.5 size-4 text-emerald-600" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="min-h-14 items-start rounded-lg py-2"
|
||||
onSelect={() => knowledgeRetrieval.onModeChange("classic")}
|
||||
>
|
||||
<BookMarkedIcon className="mt-0.5 size-4" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">
|
||||
经典检索
|
||||
{knowledgeRetrieval.smartIsDefault ? "(候选)" : "(新对话默认)"}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
沿用原有知识库检索与回答方式
|
||||
</div>
|
||||
</div>
|
||||
{knowledgeRetrieval.mode === "classic" && (
|
||||
<CheckIcon className="mt-0.5 size-4 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<p
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs leading-5",
|
||||
knowledgeRetrieval.smartAvailable
|
||||
? "text-muted-foreground"
|
||||
: "text-amber-600 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
{knowledgeRetrieval.smartAvailabilityReason}
|
||||
</p>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{!frontendRagActive && promptPrefix && (
|
||||
<Tooltip
|
||||
content={
|
||||
@@ -1374,8 +1513,18 @@ export function InputBox({
|
||||
"size-7 rounded-lg border-0 bg-[var(--primary)] text-[var(--button-foreground,var(--primary-foreground))] hover:opacity-90 disabled:bg-muted disabled:text-muted-foreground/40 dark:bg-[var(--primary)] dark:border-0 dark:text-[var(--button-foreground,var(--primary-foreground))] dark:hover:opacity-90 dark:disabled:bg-muted",
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
if (composerStatus !== "streaming") return;
|
||||
// A deliberate press of the square button stops the stream.
|
||||
// Prevent the enclosing form from treating it as a new submit.
|
||||
event.preventDefault();
|
||||
handleStopCurrentGeneration();
|
||||
}}
|
||||
variant="outline"
|
||||
status={status}
|
||||
status={composerStatus}
|
||||
{...(documentRewriteActive
|
||||
? { "aria-label": "停止 AI 全文改写" }
|
||||
: {})}
|
||||
/>
|
||||
</PromptInputTools>
|
||||
</PromptInputFooter>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import { resolveInputMode } from "./input-mode.ts";
|
||||
|
||||
test("keeps plan and subagent modes for models without native thinking", () => {
|
||||
assert.equal(resolveInputMode("pro", false), "pro");
|
||||
assert.equal(resolveInputMode("ultra", false), "ultra");
|
||||
});
|
||||
|
||||
test("falls back only when native thinking mode is unsupported", () => {
|
||||
assert.equal(resolveInputMode("thinking", false), "flash");
|
||||
assert.equal(resolveInputMode("thinking", true), "thinking");
|
||||
});
|
||||
|
||||
test("keeps the existing defaults when no mode has been chosen", () => {
|
||||
assert.equal(resolveInputMode(undefined, false), "flash");
|
||||
assert.equal(resolveInputMode(undefined, true), "pro");
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
/**
|
||||
* Keep plan/subagent modes available even when the selected model has no
|
||||
* native thinking switch. The backend already falls back to non-thinking for
|
||||
* that model, while `pro` and `ultra` still control planning and subagents.
|
||||
* Only the model-specific `thinking` mode requires `supports_thinking`.
|
||||
*/
|
||||
export function resolveInputMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
): InputMode {
|
||||
if (mode === "thinking" && !supportsThinking) {
|
||||
return "flash";
|
||||
}
|
||||
if (mode) {
|
||||
return mode;
|
||||
}
|
||||
return supportsThinking ? "pro" : "flash";
|
||||
}
|
||||
@@ -1,40 +1,44 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookOpen, Database } from "lucide-react";
|
||||
import { BookOpen /* , Database, SearchCheck */ } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
// import { canAccessAssistantKnowledge } from "@/core/llmwiki/assistant-knowledge-access-policy";
|
||||
// import {
|
||||
// useAssistantKnowledgeVisibility,
|
||||
// useUpdateAssistantKnowledgeVisibility,
|
||||
// } from "@/core/system-settings/hooks";
|
||||
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
|
||||
import {
|
||||
loadKnowledgeBaseProductVersion,
|
||||
loadOrdinaryKnowledgeLanding,
|
||||
saveOrdinaryKnowledgeLanding,
|
||||
type OrdinaryKnowledgeLanding,
|
||||
type KnowledgeBaseProductVersion,
|
||||
updateKnowledgeBaseProductVersion,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
export const KNOWLEDGE_PRODUCT_KEY = ["system-settings", "knowledge-base-product-version"] as const;
|
||||
const LANDING_KEY = ["system-settings", "ordinary-knowledge-landing"] as const;
|
||||
|
||||
export function useOrdinaryKnowledgeLanding() {
|
||||
return useQuery({ queryKey: LANDING_KEY, queryFn: loadOrdinaryKnowledgeLanding, staleTime: 0 });
|
||||
export function useOrdinaryKnowledgeLanding(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: LANDING_KEY,
|
||||
queryFn: ({ signal }) => loadOrdinaryKnowledgeLanding(signal),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
export const KNOWLEDGE_NORMAL_VIEW_VALUE = "normal";
|
||||
export const KNOWLEDGE_NORMAL_VIEW_SEARCH = `?view=${KNOWLEDGE_NORMAL_VIEW_VALUE}`;
|
||||
|
||||
export function useKnowledgeBaseProductVersion() {
|
||||
return useQuery({ queryKey: KNOWLEDGE_PRODUCT_KEY, queryFn: loadKnowledgeBaseProductVersion, staleTime: 60_000 });
|
||||
}
|
||||
|
||||
export function KnowledgeProductNav() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const client = useQueryClient();
|
||||
const version = useKnowledgeBaseProductVersion();
|
||||
const landing = useOrdinaryKnowledgeLanding();
|
||||
// const assistantVisibility = useAssistantKnowledgeVisibility();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const landing = useOrdinaryKnowledgeLanding(isAdmin);
|
||||
const saveLanding = useMutation({
|
||||
mutationFn: saveOrdinaryKnowledgeLanding,
|
||||
onSuccess: (mode) => { client.setQueryData(LANDING_KEY, mode); toast.success("普通知识库默认页面已更新"); },
|
||||
@@ -42,24 +46,32 @@ export function KnowledgeProductNav() {
|
||||
});
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const normalAvailable = runtime.data?.provider === "weknora" && Boolean(runtime.data.api_base_url?.trim());
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const save = useMutation({
|
||||
mutationFn: updateKnowledgeBaseProductVersion,
|
||||
onSuccess: (value) => {
|
||||
client.setQueryData(KNOWLEDGE_PRODUCT_KEY, value);
|
||||
toast.success(`默认知识库体验已切换为 ${value}`);
|
||||
},
|
||||
});
|
||||
// const assistantAllowed = canAccessAssistantKnowledge(
|
||||
// isAdmin,
|
||||
// assistantVisibility.isError ? undefined : assistantVisibility.data,
|
||||
// );
|
||||
// const saveAssistantVisibility = useUpdateAssistantKnowledgeVisibility();
|
||||
const retrievalDebug = location.pathname.includes("/knowledge/retrieval-debug");
|
||||
const assistant = location.pathname.includes("/knowledge/assistant");
|
||||
const ordinary = !assistant && !retrievalDebug;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b bg-card px-6 py-2">
|
||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={assistant ? "ghost" : "secondary"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<Button size="sm" disabled={!normalAvailable} title={normalAvailable ? "普通知识库" : "管理员配置知识服务地址后可用"} variant={ordinary ? "secondary" : "ghost"} onClick={() => navigate(`/page/workspace/knowledge${KNOWLEDGE_NORMAL_VIEW_SEARCH}`)}>
|
||||
<BookOpen />普通知识库
|
||||
</Button>
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
</Button>
|
||||
{/* 助手知识库、检索调试暂时隐藏,后续放开时去掉这段注释。
|
||||
{assistantAllowed && (
|
||||
<Button size="sm" variant={assistant ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/assistant")}>
|
||||
<Database />助手知识库
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button size="sm" variant={retrievalDebug ? "secondary" : "ghost"} onClick={() => navigate("/page/workspace/knowledge/retrieval-debug")}>
|
||||
<SearchCheck />检索调试
|
||||
</Button>
|
||||
)}
|
||||
*/}
|
||||
{isAdmin && (
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
普通知识库默认页面
|
||||
@@ -67,11 +79,22 @@ export function KnowledgeProductNav() {
|
||||
<SelectTrigger className="h-8 w-40" aria-label="普通知识库默认页面"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="wiki">本系统 Wiki 索引</SelectItem><SelectItem value="weknora">知识库管理页面</SelectItem></SelectContent>
|
||||
</Select>
|
||||
默认产品版本
|
||||
<Select value={version.data ?? "v1"} onValueChange={(value) => save.mutate(value as KnowledgeBaseProductVersion)}>
|
||||
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="v1">v1 普通知识库</SelectItem><SelectItem value="v2">v2 助手知识库</SelectItem></SelectContent>
|
||||
{/* 助手知识库日常展示暂时隐藏,和上面两个入口一起放开。
|
||||
助手知识库日常展示
|
||||
<Select
|
||||
disabled={assistantVisibility.isLoading || saveAssistantVisibility.isPending}
|
||||
value={assistantVisibility.data === true ? "visible" : "hidden"}
|
||||
onValueChange={(value) =>
|
||||
saveAssistantVisibility.mutate(value === "visible", {
|
||||
onSuccess: (visible) => toast.success(visible ? "已向普通用户开放助手知识库" : "已隐藏普通用户的助手知识库"),
|
||||
onError: (error) => toast.error(error.message),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-52" aria-label="助手知识库日常展示"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="hidden">隐藏(仅管理员可用)</SelectItem><SelectItem value="visible">开放给普通用户</SelectItem></SelectContent>
|
||||
</Select>
|
||||
*/}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { BookOpenText, ChevronRight, FileText } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
|
||||
import {
|
||||
classifyKnowledgeWikiSource,
|
||||
filterKnowledgeWikiSources,
|
||||
} from "@/core/llmwiki/knowledge-reference";
|
||||
import { resolveDisplaySourceChunk } from "@/core/llmwiki/source-excerpt";
|
||||
import {
|
||||
formatRetrievalScore,
|
||||
retrievalModeLabel,
|
||||
retrievalRankScoreLabel,
|
||||
} from "@/core/llmwiki/retrieval-debug";
|
||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
|
||||
import { MarkdownContent } from "./messages/markdown-content";
|
||||
|
||||
type KnowledgeWikiPanelProps = {
|
||||
highlightedReferenceId?: string | null;
|
||||
selection: KnowledgeWikiReferenceSelection;
|
||||
sources: RuntimeDisplaySource[];
|
||||
};
|
||||
|
||||
function sourceKey(source: RuntimeDisplaySource) {
|
||||
return `${source.skillName}:${source.index}:${source.id ?? source.url ?? source.title}`;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function sourceContent(source: RuntimeDisplaySource) {
|
||||
return (source.content || source.snippet || "").trim();
|
||||
}
|
||||
|
||||
function sourceMode(source: RuntimeDisplaySource) {
|
||||
return stringValue(
|
||||
source.raw?.retrieval_mode ??
|
||||
(source as RuntimeDisplaySource & { retrieval_mode?: unknown })
|
||||
.retrieval_mode,
|
||||
).toLowerCase();
|
||||
}
|
||||
|
||||
function matchedHeading(source: RuntimeDisplaySource) {
|
||||
const sections = Array.isArray(source.raw?.matched_sections)
|
||||
? source.raw.matched_sections
|
||||
: [];
|
||||
const firstSectionWithHeading = sections.find(
|
||||
(section): section is Record<string, unknown> =>
|
||||
!!section &&
|
||||
typeof section === "object" &&
|
||||
!Array.isArray(section) &&
|
||||
Boolean(stringValue((section as Record<string, unknown>).heading)),
|
||||
);
|
||||
const matchedSection =
|
||||
source.raw?.matched_section &&
|
||||
typeof source.raw.matched_section === "object" &&
|
||||
!Array.isArray(source.raw.matched_section)
|
||||
? (source.raw.matched_section as Record<string, unknown>)
|
||||
: null;
|
||||
return stringValue(
|
||||
source.raw?.matched_heading ??
|
||||
source.raw?.heading ??
|
||||
firstSectionWithHeading?.heading ??
|
||||
matchedSection?.heading,
|
||||
);
|
||||
}
|
||||
|
||||
function knowledgeSpaceName(source: RuntimeDisplaySource) {
|
||||
return (
|
||||
stringValue(source.raw?.knowledge_base_name) ||
|
||||
stringValue(source.raw?.base_name) ||
|
||||
stringValue(source.raw?.source) ||
|
||||
source.source ||
|
||||
"知识空间"
|
||||
);
|
||||
}
|
||||
|
||||
function wikiPageTypeLabel(value: unknown) {
|
||||
const normalized = stringValue(value).toLowerCase();
|
||||
if (normalized === "directory") return "目录页";
|
||||
if (normalized === "overview") return "概览页";
|
||||
if (normalized === "article") return "文章页";
|
||||
return normalized ? stringValue(value) : "";
|
||||
}
|
||||
|
||||
function plainTextPreview(value: string) {
|
||||
return value
|
||||
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*\d+[.)]\s+/gm, "")
|
||||
.replace(/[>*_`~|]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cardPreview(source: RuntimeDisplaySource) {
|
||||
const summary = stringValue(source.raw?.summary);
|
||||
return plainTextPreview(sourceContent(source) || summary).slice(0, 420);
|
||||
}
|
||||
|
||||
function updatedAtLabel(source: RuntimeDisplaySource) {
|
||||
const value =
|
||||
source.time ||
|
||||
stringValue(source.raw?.updated_at) ||
|
||||
stringValue(source.raw?.remote_updated_at);
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime())
|
||||
? value
|
||||
: date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function scoreLabel(source: RuntimeDisplaySource) {
|
||||
const score = formatRetrievalScore(source.score);
|
||||
if (!score) return "";
|
||||
const label = retrievalRankScoreLabel(
|
||||
source.raw?.rank_score_semantics,
|
||||
Boolean(source.raw?.channel_score || source.raw?.vector_score),
|
||||
);
|
||||
return label ? `${label} ${score}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge-space references keep both evidence layers visible: formatted Wiki
|
||||
* pages and their original document chunks. They deliberately use dedicated
|
||||
* Wiki cards instead of the generic numbered reference-card presentation.
|
||||
*/
|
||||
export function KnowledgeWikiPanel({
|
||||
highlightedReferenceId,
|
||||
selection,
|
||||
sources,
|
||||
}: KnowledgeWikiPanelProps) {
|
||||
const wikiSources = useMemo(
|
||||
() => filterKnowledgeWikiSources(sources, selection),
|
||||
[selection, sources],
|
||||
);
|
||||
const orderedSources = useMemo(() => {
|
||||
const formattedWiki = wikiSources.filter(
|
||||
(source) => classifyKnowledgeWikiSource(source) === "wiki",
|
||||
);
|
||||
const rawDocuments = wikiSources.filter(
|
||||
(source) => classifyKnowledgeWikiSource(source) === "raw",
|
||||
);
|
||||
return [...formattedWiki, ...rawDocuments];
|
||||
}, [wikiSources]);
|
||||
const [openSourceKey, setOpenSourceKey] = useState<string | null>(null);
|
||||
const openSource =
|
||||
wikiSources.find((source) => sourceKey(source) === openSourceKey) ?? null;
|
||||
const previewTarget = openSource
|
||||
? resolveWeKnoraSourceTarget({
|
||||
...(openSource.raw ?? {}),
|
||||
url: openSource.url,
|
||||
})
|
||||
: null;
|
||||
const openContent = openSource ? sourceContent(openSource) : "";
|
||||
const openChunk = openSource ? resolveDisplaySourceChunk(openSource) : "";
|
||||
const selectedSpaceCount =
|
||||
selection.knowledgeBaseIds.length +
|
||||
selection.assistantKnowledgeBaseIds.length;
|
||||
const formattedWikiCount = wikiSources.filter(
|
||||
(source) => classifyKnowledgeWikiSource(source) === "wiki",
|
||||
).length;
|
||||
const rawDocumentCount = wikiSources.length - formattedWikiCount;
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden">
|
||||
<header className="shrink-0 border-b border-emerald-200/60 px-1 pb-3 dark:border-emerald-900/50">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-emerald-600 text-white shadow-sm shadow-emerald-600/20">
|
||||
<BookOpenText className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<h2 className="text-foreground text-base font-semibold tracking-tight">
|
||||
Wiki 资料
|
||||
</h2>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{wikiSources.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">
|
||||
来自已选 {selectedSpaceCount} 个知识空间,点击卡片查看完整内容。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{wikiSources.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
<Badge className="border-emerald-200 bg-emerald-100 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
|
||||
Wiki 页面 {formattedWikiCount}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="bg-background/80">
|
||||
原始资料 {rawDocumentCount}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-3 pb-6">
|
||||
{orderedSources.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{orderedSources.map((source) => (
|
||||
<KnowledgeWikiCard
|
||||
key={sourceKey(source)}
|
||||
highlighted={highlightedReferenceId === String(source.index)}
|
||||
source={source}
|
||||
onOpen={() => setOpenSourceKey(sourceKey(source))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex min-h-48 flex-col items-center justify-center rounded-xl border border-dashed bg-muted/20 px-6 text-center text-sm">
|
||||
<BookOpenText className="mb-3 size-8 opacity-50" />
|
||||
<span>本轮未检索到所选知识空间内的 Wiki 资料。</span>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{openSource && previewTarget && (
|
||||
<WeKnoraSourceDrawer
|
||||
open
|
||||
target={previewTarget}
|
||||
title={openSource.title}
|
||||
quote={openChunk}
|
||||
collapseRetrievedExcerptByDefault={
|
||||
classifyKnowledgeWikiSource(openSource) === "wiki"
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOpenSourceKey(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Sheet
|
||||
open={Boolean(openSource && !previewTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setOpenSourceKey(null);
|
||||
}}
|
||||
>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b border-emerald-200/60 bg-emerald-50/60 px-5 py-4 pr-12 text-left dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="flex items-start gap-3 pr-8">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-emerald-600 text-white">
|
||||
<BookOpenText className="size-4.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="text-base leading-relaxed">
|
||||
{openSource?.title || "Wiki 内容"}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="mt-1">
|
||||
{openSource ? knowledgeSpaceName(openSource) : "知识空间"}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
||||
{openContent ? (
|
||||
<article className="mx-auto max-w-3xl rounded-xl bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={openContent}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</article>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm">
|
||||
当前 Wiki 暂无可展示内容。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KnowledgeWikiCard({
|
||||
highlighted,
|
||||
source,
|
||||
onOpen,
|
||||
}: {
|
||||
highlighted: boolean;
|
||||
source: RuntimeDisplaySource;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const formattedWiki = classifyKnowledgeWikiSource(source) === "wiki";
|
||||
const heading = matchedHeading(source);
|
||||
const pageType = wikiPageTypeLabel(source.raw?.page_type);
|
||||
const mode = sourceMode(source);
|
||||
const modeLabel = mode ? retrievalModeLabel(mode) : "";
|
||||
const preview = cardPreview(source);
|
||||
const score = scoreLabel(source);
|
||||
const updatedAt = updatedAtLabel(source);
|
||||
const target = resolveWeKnoraSourceTarget({
|
||||
...(source.raw ?? {}),
|
||||
url: source.url,
|
||||
});
|
||||
const canOpen = Boolean(target || sourceContent(source));
|
||||
|
||||
return (
|
||||
<button
|
||||
id={`reference-${source.index}`}
|
||||
type="button"
|
||||
disabled={!canOpen}
|
||||
className={cn(
|
||||
"group w-full scroll-mt-12 rounded-xl border bg-background/90 p-4 text-left shadow-sm transition",
|
||||
"focus-visible:ring-2 focus-visible:outline-none disabled:cursor-default disabled:opacity-70",
|
||||
formattedWiki
|
||||
? "border-emerald-200/80 hover:-translate-y-0.5 hover:border-emerald-400 hover:shadow-md focus-visible:ring-emerald-500/40 dark:border-emerald-900/70"
|
||||
: "border-sky-200/80 hover:-translate-y-0.5 hover:border-sky-400 hover:shadow-md focus-visible:ring-sky-500/40 dark:border-sky-900/70",
|
||||
highlighted &&
|
||||
(formattedWiki
|
||||
? "border-emerald-500 bg-emerald-50/80 ring-2 ring-emerald-500/20 dark:bg-emerald-950/25"
|
||||
: "border-sky-500 bg-sky-50/80 ring-2 ring-sky-500/20 dark:bg-sky-950/25"),
|
||||
)}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg",
|
||||
formattedWiki
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
|
||||
)}
|
||||
>
|
||||
{formattedWiki ? (
|
||||
<BookOpenText className="size-4.5" />
|
||||
) : (
|
||||
<FileText className="size-4.5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
formattedWiki
|
||||
? "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
: "bg-sky-50 text-sky-700 dark:bg-sky-950 dark:text-sky-300",
|
||||
)}
|
||||
>
|
||||
{formattedWiki ? "Wiki 页面" : "原始资料"}
|
||||
</Badge>
|
||||
{formattedWiki && pageType && (
|
||||
<Badge variant="outline">{pageType}</Badge>
|
||||
)}
|
||||
{modeLabel && <Badge variant="outline">{modeLabel}</Badge>}
|
||||
<span className="text-muted-foreground min-w-0 truncate text-xs">
|
||||
{knowledgeSpaceName(source)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-foreground mt-2 line-clamp-2 text-[15px] leading-6 font-semibold">
|
||||
{source.title ||
|
||||
(formattedWiki ? "未命名 Wiki 页面" : "未命名原始资料")}
|
||||
</h3>
|
||||
{heading && (
|
||||
<div className="mt-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
命中章节:{heading}
|
||||
</div>
|
||||
)}
|
||||
{preview && (
|
||||
<p className="text-muted-foreground mt-2 line-clamp-3 whitespace-pre-wrap text-sm leading-6">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-muted-foreground mt-3 flex flex-wrap items-center justify-between gap-2 border-t pt-3 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{score && <span>{score}</span>}
|
||||
{updatedAt && <span>更新于 {updatedAt}</span>}
|
||||
</div>
|
||||
{canOpen && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center font-medium transition group-hover:translate-x-0.5",
|
||||
formattedWiki
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-sky-700 dark:text-sky-300",
|
||||
)}
|
||||
>
|
||||
{target
|
||||
? formattedWiki
|
||||
? "查看完整 Wiki"
|
||||
: "查看原始资料"
|
||||
: "查看命中内容"}
|
||||
<ChevronRight className="ml-1 size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAssistantKnowledgeAccess } from "@/core/llmwiki/assistant-knowledge-access";
|
||||
import { ASSISTANT_KNOWLEDGE_UI_VISIBLE } from "@/core/llmwiki/assistant-knowledge-ui";
|
||||
import { userFacingKnowledgeSuggestedQuestions } from "@/core/llmwiki/knowledge-suggested-questions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AssistantKnowledgeBase,
|
||||
@@ -35,6 +38,28 @@ import {
|
||||
|
||||
type SelectorSource = "all" | "agent";
|
||||
type SelectorVariant = "popover" | "panel";
|
||||
const EMPTY_IDS: string[] = [];
|
||||
const IGNORE_ASSISTANT_CHANGE = () => undefined;
|
||||
|
||||
export function buildKnowledgeSpaceQuestions(names: string[]): string[] {
|
||||
const cleanNames = names.map((name) => name.trim()).filter(Boolean);
|
||||
if (cleanNames.length === 0) return [];
|
||||
if (cleanNames.length === 1) {
|
||||
const name = cleanNames[0]!;
|
||||
return [
|
||||
`请概括「${name}」知识空间的主要内容、适用场景和资料边界。`,
|
||||
`「${name}」包含哪些核心主题?请按重要性说明。`,
|
||||
`请梳理「${name}」中的关键实体、概念及它们之间的关系。`,
|
||||
`根据「${name}」,有哪些值得关注的问题、风险或行动建议?`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`请综合所选 ${cleanNames.length} 个知识空间,总结共同主题、差异和互补信息。`,
|
||||
`请对比「${cleanNames.slice(0, 3).join("」「")}」中的关键观点。`,
|
||||
"请梳理所选知识空间中的关键实体、概念及跨库关系。",
|
||||
"所选知识空间中有哪些相互冲突或证据不足的结论?",
|
||||
];
|
||||
}
|
||||
|
||||
function groupRows(rows: LlmWikiKnowledgeBase[]) {
|
||||
return [
|
||||
@@ -323,9 +348,14 @@ function SelectionContent({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{readOnly ? `本对话已选 ${selectedCount} 个` : `已选择 ${selectedCount} 个`}
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-primary">仅检索已选知识空间,不联网</div>
|
||||
)}
|
||||
</div>
|
||||
{readOnly ? (
|
||||
<span className="text-[11px] text-muted-foreground">对话开始后不可更改</span>
|
||||
) : (
|
||||
@@ -363,9 +393,10 @@ function SelectionContent({
|
||||
|
||||
export function LlmWikiKnowledgeSelector({
|
||||
value,
|
||||
assistantValue = [],
|
||||
assistantValue = EMPTY_IDS,
|
||||
onChange,
|
||||
onAssistantChange = () => undefined,
|
||||
onAssistantChange,
|
||||
onSuggestedQuestionsChange,
|
||||
source = "all",
|
||||
agentId,
|
||||
autoSelectAll = false,
|
||||
@@ -373,12 +404,15 @@ export function LlmWikiKnowledgeSelector({
|
||||
readOnly = false,
|
||||
className,
|
||||
label = "知识空间",
|
||||
panelTitle = "绑定知识空间",
|
||||
panelDescription = "新建该智能体对话时,只能从已绑定空间中选择本次检索范围。",
|
||||
variant = "popover",
|
||||
}: {
|
||||
value: string[];
|
||||
assistantValue?: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
onAssistantChange?: (value: string[]) => void;
|
||||
onSuggestedQuestionsChange?: (questions: string[]) => void;
|
||||
source?: SelectorSource;
|
||||
agentId?: string | null;
|
||||
autoSelectAll?: boolean;
|
||||
@@ -387,31 +421,59 @@ export function LlmWikiKnowledgeSelector({
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
panelTitle?: string;
|
||||
panelDescription?: string;
|
||||
variant?: SelectorVariant;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const assistantAccess = useAssistantKnowledgeAccess({
|
||||
enabled:
|
||||
ASSISTANT_KNOWLEDGE_UI_VISIBLE && onAssistantChange !== undefined,
|
||||
});
|
||||
const assistantAllowed =
|
||||
ASSISTANT_KNOWLEDGE_UI_VISIBLE &&
|
||||
onAssistantChange !== undefined &&
|
||||
assistantAccess.canAccess;
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const weknoraEnabled = runtime.data?.provider === "weknora";
|
||||
const shouldLoadOptions =
|
||||
variant === "panel" ||
|
||||
open ||
|
||||
autoSelectAll ||
|
||||
value.length > 0 ||
|
||||
assistantValue.length > 0;
|
||||
const all = useLlmWikiKnowledgeBases(
|
||||
"all",
|
||||
weknoraEnabled,
|
||||
weknoraEnabled && shouldLoadOptions,
|
||||
);
|
||||
const agent = useAgentLlmWikiKnowledgeBases(
|
||||
agentId,
|
||||
weknoraEnabled && source === "agent" && !!agentId,
|
||||
weknoraEnabled && shouldLoadOptions && source === "agent" && !!agentId,
|
||||
);
|
||||
const assistantBases = useAssistantKnowledgeBases();
|
||||
const rows = weknoraEnabled ? (all.data ?? []) : [];
|
||||
const assistantRows = assistantBases.data ?? [];
|
||||
const assistantBases = useAssistantKnowledgeBases(
|
||||
assistantAllowed &&
|
||||
(shouldLoadOptions ||
|
||||
runtime.isError ||
|
||||
(!runtime.isLoading && !weknoraEnabled)),
|
||||
);
|
||||
// Stable empty results are essential: suggested questions are reported to
|
||||
// the parent, so a fresh [] on every render creates an effect/update loop
|
||||
// before lazy knowledge queries have loaded (or when they are disabled).
|
||||
const rows = useMemo(() => weknoraEnabled ? (all.data ?? []) : [], [all.data, weknoraEnabled]);
|
||||
const assistantRows = useMemo(
|
||||
() => assistantAllowed ? (assistantBases.data ?? []) : [],
|
||||
[assistantAllowed, assistantBases.data],
|
||||
);
|
||||
const effectiveAssistantValue = assistantAllowed ? assistantValue : EMPTY_IDS;
|
||||
const defaultIds = useMemo(() => {
|
||||
if (source !== "agent") {
|
||||
return rows.map((item) => item.id);
|
||||
}
|
||||
return (agent.data ?? []).map((item) => item.id);
|
||||
}, [agent.data, rows, source]);
|
||||
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || assistantBases.isLoading || (source === "agent" && !!agentId && agent.isLoading);
|
||||
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? assistantBases.error;
|
||||
const loading = runtime.isLoading || (weknoraEnabled && all.isLoading) || (assistantAllowed && assistantBases.isLoading) || (source === "agent" && !!agentId && agent.isLoading);
|
||||
const error = (weknoraEnabled ? all.error ?? (source === "agent" ? agent.error : null) : null) ?? (assistantAllowed ? assistantBases.error : null);
|
||||
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
|
||||
const assistantAvailableIds = useMemo(
|
||||
() => new Set(assistantRows.map((item) => item.id)),
|
||||
@@ -419,6 +481,41 @@ export function LlmWikiKnowledgeSelector({
|
||||
);
|
||||
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
|
||||
const initialized = useRef<string | null>(null);
|
||||
const selectedNames = useMemo(
|
||||
() => value.map((id) => rows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
||||
[rows, value],
|
||||
);
|
||||
const selectedAssistantNames = useMemo(
|
||||
() => effectiveAssistantValue.map((id) => assistantRows.find((item) => item.id === id)?.name).filter((name): name is string => !!name),
|
||||
[assistantRows, effectiveAssistantValue],
|
||||
);
|
||||
const allSelectedNames = useMemo(
|
||||
() => [...selectedNames, ...selectedAssistantNames],
|
||||
[selectedAssistantNames, selectedNames],
|
||||
);
|
||||
const selectedConfiguredQuestions = useMemo(
|
||||
() =>
|
||||
userFacingKnowledgeSuggestedQuestions(
|
||||
value.flatMap(
|
||||
(id) => rows.find((item) => item.id === id)?.test_questions ?? [],
|
||||
),
|
||||
),
|
||||
[rows, value],
|
||||
);
|
||||
const suggestedQuestions = useMemo(
|
||||
() => {
|
||||
if (value.length === 1 && effectiveAssistantValue.length === 0 && selectedConfiguredQuestions.length > 0) {
|
||||
return selectedConfiguredQuestions.slice(0, 12);
|
||||
}
|
||||
const general = buildKnowledgeSpaceQuestions(allSelectedNames);
|
||||
return Array.from(new Set([...selectedConfiguredQuestions, ...general])).slice(0, 12);
|
||||
},
|
||||
[allSelectedNames, effectiveAssistantValue.length, selectedConfiguredQuestions, value.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onSuggestedQuestionsChange?.(suggestedQuestions);
|
||||
}, [onSuggestedQuestionsChange, suggestedQuestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime.isLoading && !weknoraEnabled) {
|
||||
@@ -437,27 +534,36 @@ export function LlmWikiKnowledgeSelector({
|
||||
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, runtime.isLoading, source, value, weknoraEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assistantBases.data) return;
|
||||
const valid = assistantValue.filter((id) => assistantAvailableIds.has(id));
|
||||
if (valid.length !== assistantValue.length || valid.some((id, index) => id !== assistantValue[index])) {
|
||||
if (!assistantAllowed || !assistantBases.data || !onAssistantChange) return;
|
||||
const valid = effectiveAssistantValue.filter((id) => assistantAvailableIds.has(id));
|
||||
if (valid.length !== effectiveAssistantValue.length || valid.some((id, index) => id !== effectiveAssistantValue[index])) {
|
||||
onAssistantChange(valid);
|
||||
}
|
||||
}, [assistantAvailableIds, assistantBases.data, assistantValue, onAssistantChange]);
|
||||
}, [assistantAllowed, assistantAvailableIds, assistantBases.data, effectiveAssistantValue, onAssistantChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setSearch("");
|
||||
}, [open]);
|
||||
|
||||
if ((runtime.isError || !weknoraEnabled) && !assistantBases.isLoading && assistantRows.length === 0) return null;
|
||||
if (
|
||||
!runtime.isLoading &&
|
||||
(runtime.isError || !weknoraEnabled) &&
|
||||
(!assistantAllowed || !assistantBases.isLoading) &&
|
||||
assistantRows.length === 0
|
||||
) return null;
|
||||
|
||||
const content = (
|
||||
<SelectionContent
|
||||
rows={rows}
|
||||
assistantRows={assistantRows}
|
||||
value={value}
|
||||
assistantValue={assistantValue}
|
||||
assistantValue={effectiveAssistantValue}
|
||||
onChange={onChange}
|
||||
onAssistantChange={onAssistantChange}
|
||||
onAssistantChange={
|
||||
assistantAllowed
|
||||
? (onAssistantChange ?? IGNORE_ASSISTANT_CHANGE)
|
||||
: IGNORE_ASSISTANT_CHANGE
|
||||
}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
loading={loading}
|
||||
@@ -474,27 +580,20 @@ export function LlmWikiKnowledgeSelector({
|
||||
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Database className="size-4 text-primary" />绑定知识空间
|
||||
<Database className="size-4 text-primary" />{panelTitle}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
|
||||
{panelDescription}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length + assistantValue.length} 个</Badge>
|
||||
<Badge variant="secondary" className="shrink-0">{value.length + effectiveAssistantValue.length} 个</Badge>
|
||||
</div>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedNames = value
|
||||
.map((id) => rows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const selectedAssistantNames = assistantValue
|
||||
.map((id) => assistantRows.find((item) => item.id === id)?.name)
|
||||
.filter((name): name is string => !!name);
|
||||
const allSelectedNames = [...selectedNames, ...selectedAssistantNames];
|
||||
const selectedCount = value.length + assistantValue.length;
|
||||
const selectedCount = value.length + effectiveAssistantValue.length;
|
||||
const selectedLabel = allSelectedNames.length > 0
|
||||
? allSelectedNames.join("、")
|
||||
: selectedCount > 0
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
Loader2Icon,
|
||||
ThumbsDownIcon,
|
||||
ThumbsUpIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useUpsertAnswerFeedback,
|
||||
type AnswerFeedbackContext,
|
||||
type AnswerFeedbackReason,
|
||||
} from "@/strategy-components/api/answer-feedback";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
const REASON_OPTIONS: Array<{
|
||||
value: AnswerFeedbackReason;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "incorrect_or_incomplete", label: "不正确或不完整" },
|
||||
{ value: "unmet_expectations", label: "与期望不符" },
|
||||
{ value: "slow_or_problematic", label: "速度慢或存在问题" },
|
||||
{ value: "style_or_tone", label: "风格或语气" },
|
||||
{ value: "safety_or_legal", label: "安全或法律疑虑" },
|
||||
{ value: "other", label: "其他" },
|
||||
];
|
||||
|
||||
export function AnswerFeedbackControl({
|
||||
threadId,
|
||||
messageId,
|
||||
context,
|
||||
}: {
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
context: AnswerFeedbackContext;
|
||||
}) {
|
||||
const saveFeedback = useUpsertAnswerFeedback();
|
||||
const feedback = context?.feedback ?? null;
|
||||
const [poorDialogOpen, setPoorDialogOpen] = useState(false);
|
||||
const [reasons, setReasons] = useState<AnswerFeedbackReason[]>([]);
|
||||
const [detail, setDetail] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!poorDialogOpen) return;
|
||||
setReasons(feedback?.rating === -1 ? feedback.reasons : []);
|
||||
setDetail(
|
||||
feedback?.rating === -1
|
||||
? (feedback.detail ?? feedback.comment ?? "")
|
||||
: "",
|
||||
);
|
||||
}, [feedback, poorDialogOpen]);
|
||||
|
||||
const submitPositive = async () => {
|
||||
try {
|
||||
await saveFeedback.mutateAsync({
|
||||
threadId,
|
||||
messageId,
|
||||
input: { rating: 1, reasons: [], detail: null },
|
||||
});
|
||||
toast.success("感谢你的评价");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "评价提交失败");
|
||||
}
|
||||
};
|
||||
|
||||
const submitNegative = async () => {
|
||||
if (reasons.length === 0) return;
|
||||
try {
|
||||
await saveFeedback.mutateAsync({
|
||||
threadId,
|
||||
messageId,
|
||||
input: {
|
||||
rating: -1,
|
||||
reasons,
|
||||
detail: detail.trim() || null,
|
||||
},
|
||||
});
|
||||
setPoorDialogOpen(false);
|
||||
toast.success("反馈已提交,感谢你的帮助");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "反馈提交失败");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReason = (reason: AnswerFeedbackReason) => {
|
||||
setReasons((current) =>
|
||||
current.includes(reason)
|
||||
? current.filter((item) => item !== reason)
|
||||
: [...current, reason],
|
||||
);
|
||||
};
|
||||
|
||||
const triggerIcon =
|
||||
feedback?.rating === 1 ? (
|
||||
<ThumbsUpIcon
|
||||
size={12}
|
||||
className="fill-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||
/>
|
||||
) : feedback?.rating === -1 ? (
|
||||
<ThumbsDownIcon
|
||||
size={12}
|
||||
className="fill-amber-500/15 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex items-center gap-px" aria-hidden="true">
|
||||
<ThumbsUpIcon className="size-3" />
|
||||
<ThumbsDownIcon className="size-3" />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<Tooltip content="评价回复">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"size-6",
|
||||
feedback?.rating === 1 &&
|
||||
"bg-emerald-500/10 hover:bg-emerald-500/15",
|
||||
feedback?.rating === -1 &&
|
||||
"bg-amber-500/10 hover:bg-amber-500/15",
|
||||
)}
|
||||
disabled={saveFeedback.isPending}
|
||||
aria-label={
|
||||
feedback?.rating === 1
|
||||
? "已评价为回答优秀"
|
||||
: feedback?.rating === -1
|
||||
? "已评价为回答不佳"
|
||||
: "评价回复"
|
||||
}
|
||||
>
|
||||
{saveFeedback.isPending ? (
|
||||
<Loader2Icon className="size-3 animate-spin" />
|
||||
) : (
|
||||
triggerIcon
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" sideOffset={6} className="min-w-44 p-1.5">
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 py-2"
|
||||
onSelect={() => void submitPositive()}
|
||||
>
|
||||
<ThumbsUpIcon className="size-4" />
|
||||
<span>回答优秀</span>
|
||||
{feedback?.rating === 1 && (
|
||||
<CheckIcon className="text-primary ml-auto size-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 py-2"
|
||||
onSelect={() => setPoorDialogOpen(true)}
|
||||
>
|
||||
<ThumbsDownIcon className="size-4" />
|
||||
<span>回答不佳</span>
|
||||
{feedback?.rating === -1 && (
|
||||
<CheckIcon className="text-primary ml-auto size-4" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={poorDialogOpen} onOpenChange={setPoorDialogOpen}>
|
||||
<DialogContent className="gap-5 sm:max-w-[min(48rem,calc(100vw-2rem))]">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle className="text-xl font-medium">分享反馈</DialogTitle>
|
||||
<DialogDescription>
|
||||
可多选这条回答需要改进的方面。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{REASON_OPTIONS.map((option) => {
|
||||
const selected = reasons.includes(option.value);
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleReason(option.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-4 py-2 text-sm transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border bg-background hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={detail}
|
||||
onChange={(event) => setDetail(event.currentTarget.value)}
|
||||
maxLength={2000}
|
||||
placeholder="分享详细信息(可选)"
|
||||
className="min-h-28 resize-y"
|
||||
/>
|
||||
|
||||
<div className="bg-muted/60 text-muted-foreground rounded-lg px-4 py-3 text-sm">
|
||||
你的反馈将用于改进智能体的回答质量和稳定性。
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void submitNegative()}
|
||||
disabled={reasons.length === 0 || saveFeedback.isPending}
|
||||
>
|
||||
{saveFeedback.isPending && (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
)}
|
||||
提交
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility.ts";
|
||||
|
||||
function message(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: "answer-1",
|
||||
type: "ai",
|
||||
content: "A persisted answer",
|
||||
additional_kwargs: {},
|
||||
...overrides,
|
||||
} as Message;
|
||||
}
|
||||
|
||||
test("accepts a completed persisted assistant answer", () => {
|
||||
assert.equal(isEligibleAnswerFeedbackMessage(message()), true);
|
||||
});
|
||||
test("rejects loading, user, optimistic and empty messages", () => {
|
||||
assert.equal(isEligibleAnswerFeedbackMessage(message(), true), false);
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(message({ type: "human" })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(message({ id: "opt-ai-1" })),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(message({ content: " " })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects tool-call shells and virtual business cards", () => {
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(
|
||||
message({
|
||||
tool_calls: [{ id: "tool-1", name: "search", args: {} }],
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(
|
||||
message({
|
||||
additional_kwargs: { setup_research_approval: { stage: "plan" } },
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects ids outside the message-scoped API contract", () => {
|
||||
assert.equal(
|
||||
isEligibleAnswerFeedbackMessage(message({ id: "x".repeat(65) })),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
function hasTextContent(content: Message["content"]): boolean {
|
||||
if (typeof content === "string") return content.trim().length > 0;
|
||||
if (!Array.isArray(content)) return false;
|
||||
return content.some((part) => {
|
||||
if (!part || typeof part !== "object") return false;
|
||||
const text = "text" in part ? part.text : undefined;
|
||||
return typeof text === "string" && text.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Only completed, persisted, user-visible assistant answers can be rated.
|
||||
* Tool-call shells, optimistic/transient rows and special virtual cards are
|
||||
* deliberately excluded because they are not standalone answers.
|
||||
*/
|
||||
export function isEligibleAnswerFeedbackMessage(
|
||||
message: Message,
|
||||
isLoading = false,
|
||||
): boolean {
|
||||
if (
|
||||
isLoading ||
|
||||
message.type !== "ai" ||
|
||||
!message.id ||
|
||||
message.id.length > 64
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (message.id.startsWith("opt-")) return false;
|
||||
if (!hasTextContent(message.content)) return false;
|
||||
|
||||
const directToolCalls = "tool_calls" in message ? message.tool_calls : null;
|
||||
const additional = message.additional_kwargs ?? {};
|
||||
const additionalToolCalls = additional.tool_calls;
|
||||
if (
|
||||
(Array.isArray(directToolCalls) && directToolCalls.length > 0) ||
|
||||
(Array.isArray(additionalToolCalls) && additionalToolCalls.length > 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const virtualCardKeys = [
|
||||
"setup_agent_approval",
|
||||
"setup_writing_approval",
|
||||
"setup_report_structure_approval",
|
||||
"setup_research_approval",
|
||||
"task_report_import_approval",
|
||||
"report_collaboration_card",
|
||||
];
|
||||
return !virtualCardKeys.some((key) => additional[key] !== undefined);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { KnowledgeWikiReferenceSelection } from "@/core/llmwiki/knowledge-reference";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
@@ -13,6 +14,11 @@ export interface ThreadContextType {
|
||||
* from the admin-controlled manual reference-mode button.
|
||||
*/
|
||||
knowledgeReferencesEnabled?: boolean;
|
||||
/**
|
||||
* Server-scoped knowledge selection for the dedicated Wiki reference view.
|
||||
* Sources outside these ids are never displayed as knowledge evidence.
|
||||
*/
|
||||
knowledgeReferences?: KnowledgeWikiReferenceSelection;
|
||||
/**
|
||||
* When set, the conversation is rendered from a public read-only share.
|
||||
* Artifact/image URLs then resolve to the unauthenticated
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronsDownIcon,
|
||||
CopyIcon,
|
||||
FilePenLineIcon,
|
||||
LightbulbIcon,
|
||||
Loader2Icon,
|
||||
@@ -14,6 +15,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArtifactFileList } from "@/components/workspace/artifacts/artifact-file-list";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { updateArtifactContent } from "@/core/artifacts/api";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { copyTextToClipboard } from "@/core/clipboard";
|
||||
import {
|
||||
DEEP_RESEARCH_REPORT_REWRITE_REQUESTED_EVENT,
|
||||
DOCUMENT_REWRITE_LOCAL_UNDO_GUARD_EVENT,
|
||||
@@ -42,7 +45,7 @@ import { DocumentRewriteDiffDialog } from "./document-rewrite-diff-dialog";
|
||||
const REWRITE_STEPS = [
|
||||
{ key: "snapshot", label: "固定原文版本" },
|
||||
{ key: "requirements", label: "理解改写要求" },
|
||||
{ key: "writing", label: "流式重写 Markdown" },
|
||||
{ key: "writing", label: "生成改写版 Markdown" },
|
||||
{ key: "illustrations", label: "生成文档配图" },
|
||||
{ key: "validation", label: "校验文档" },
|
||||
{ key: "comparison", label: "对比原文并生成说明" },
|
||||
@@ -221,6 +224,9 @@ export function DocumentRewriteTimeline({
|
||||
const presentation = useDocumentRewritePresentation();
|
||||
const [undoing, setUndoing] = useState(false);
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
const diagnosticText = item.diagnostic
|
||||
? JSON.stringify(item.diagnostic, null, 2)
|
||||
: "";
|
||||
|
||||
const reapplying = Boolean(item.revertedAt);
|
||||
const activeVersionId = reapplying ? item.redoVersionId : item.versionId;
|
||||
@@ -370,7 +376,7 @@ export function DocumentRewriteTimeline({
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未覆盖原文件") : "进行中"}
|
||||
{completed ? "已完成" : failed ? (isNewReportGeneration ? "未生成新报告" : "未生成改写版") : "进行中"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
@@ -440,13 +446,13 @@ export function DocumentRewriteTimeline({
|
||||
{step.key === "writing" && (
|
||||
item.writtenCharCount > 0 ? (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
正在写入右侧沙箱,已{isNewReportGeneration ? "生成" : "改写"} {item.writtenCharCount.toLocaleString()} 字。
|
||||
正在{isNewReportGeneration ? "生成新报告" : "生成改写版"},已输出 {item.writtenCharCount.toLocaleString()} 字。
|
||||
</p>
|
||||
) : active ? (
|
||||
<p aria-live="polite" className="text-muted-foreground mt-0.5 text-xs" role="status">
|
||||
{isNewReportGeneration
|
||||
? "正在等待模型开始输出;右侧将在首个字符到达后显示新报告。"
|
||||
: "正在等待模型开始输出;右侧将在首个字符到达后显示草稿。"}
|
||||
: "正在等待模型开始输出;完成后将生成一份独立的改写版文件。"}
|
||||
</p>
|
||||
) : null
|
||||
)}
|
||||
@@ -583,9 +589,42 @@ export function DocumentRewriteTimeline({
|
||||
)}
|
||||
|
||||
{item.error && (
|
||||
<div className="flex items-center gap-2 border-t px-5 py-3 text-sm text-destructive">
|
||||
<XCircleIcon className="size-4 shrink-0" />
|
||||
{item.error}
|
||||
<div className="border-t px-5 py-3 text-sm text-destructive">
|
||||
<div className="flex items-start gap-2">
|
||||
<XCircleIcon className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{item.error}</span>
|
||||
</div>
|
||||
{item.diagnostic && (
|
||||
<Collapsible className="mt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button className="h-8 gap-1.5 px-2.5 text-xs" size="sm" type="button" variant="outline">
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
查看后台诊断
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
className="h-8 gap-1.5 px-2.5 text-xs"
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(diagnosticText)
|
||||
.then(() => toast.success("诊断信息已复制"))
|
||||
.catch(() => toast.error("复制诊断信息失败"));
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
复制诊断
|
||||
</Button>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<pre className="bg-muted/55 text-foreground mt-2 max-h-72 overflow-auto rounded-lg border p-3 font-mono text-xs leading-5 whitespace-pre-wrap break-words">
|
||||
{diagnosticText}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DocumentRewriteDiffDialog
|
||||
@@ -598,3 +637,36 @@ export function DocumentRewriteTimeline({
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** A standalone chat row for a sandbox document-rewrite task. */
|
||||
export function DocumentRewriteMessage({
|
||||
item,
|
||||
threadId,
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
}: {
|
||||
item: DocumentRewritePresentation;
|
||||
threadId: string;
|
||||
onOpenArtifact?: (filepath: string) => void;
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
}) {
|
||||
const artifactPath = item.source.kind === "artifact" ? item.outputPath : undefined;
|
||||
const completed = item.stage === "completed";
|
||||
|
||||
return (
|
||||
<section aria-label={`AI 全文改写:${item.fileName}`} className="w-full space-y-3">
|
||||
<DocumentRewriteTimeline item={item} />
|
||||
{completed && artifactPath && (
|
||||
<ArtifactFileList
|
||||
fileDescriptions={{
|
||||
[artifactPath]: "AI 改写版 Markdown 文件",
|
||||
}}
|
||||
files={[artifactPath]}
|
||||
onDownloadArtifact={onDownloadArtifact}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
MessageResponse,
|
||||
type MessageResponseProps,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
||||
import {
|
||||
looksLikeHtmlDocument,
|
||||
MarkdownFallback,
|
||||
MarkdownRenderBoundary,
|
||||
streamdownPlugins,
|
||||
useStreamdownMermaidPngDownload,
|
||||
} from "@/core/streamdown";
|
||||
import {
|
||||
createMarkdownAnchorResolver,
|
||||
isInPageAnchor,
|
||||
@@ -20,6 +26,9 @@ import {
|
||||
} from "@/core/utils/markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { VisualizationBlock } from "../visualizations/visualization-block";
|
||||
import { splitVisualizationContent } from "../visualizations/visualization-spec";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
|
||||
function isExternalUrl(href: string | undefined): boolean {
|
||||
@@ -50,6 +59,7 @@ export type MarkdownContentProps = {
|
||||
/** Renders markdown content. */
|
||||
export function MarkdownContent({
|
||||
content,
|
||||
isLoading,
|
||||
rehypePlugins,
|
||||
className,
|
||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||
@@ -59,6 +69,7 @@ export function MarkdownContent({
|
||||
}: MarkdownContentProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
const visualizationSegments = useMemo(() => splitVisualizationContent(content), [content]);
|
||||
useStreamdownMermaidPngDownload(rootRef);
|
||||
const components = useMemo(() => {
|
||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
@@ -139,17 +150,72 @@ export function MarkdownContent({
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
// Full HTML pages belong in the artifact iframe. Feeding them to Streamdown
|
||||
// mid-stream can recurse forever in marked and white-screen the chat.
|
||||
if (looksLikeHtmlDocument(content)) {
|
||||
return (
|
||||
<MarkdownFallback message="正在生成 HTML 页面,请查看右侧预览。" />
|
||||
);
|
||||
}
|
||||
|
||||
// Preserve the exact legacy rendering path for ordinary Markdown. Heavy
|
||||
// visualization renderers are lazy-loaded only when a dedicated fence exists.
|
||||
if (
|
||||
visualizationSegments.length === 1 &&
|
||||
visualizationSegments[0]?.type === "markdown"
|
||||
) {
|
||||
return (
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
<MarkdownRenderBoundary resetKey={`${content.length}:${content.slice(-32)}`}>
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
</MarkdownRenderBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
{visualizationSegments.map((segment, index) =>
|
||||
segment.type === "visualization" ? (
|
||||
<VisualizationBlock
|
||||
key={`visualization-${index}`}
|
||||
language={segment.language}
|
||||
source={segment.source}
|
||||
complete={segment.complete}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : segment.content ? (
|
||||
looksLikeHtmlDocument(segment.content) ? (
|
||||
<MarkdownFallback
|
||||
key={`markdown-${index}`}
|
||||
message="正在生成 HTML 页面,请查看右侧预览。"
|
||||
/>
|
||||
) : (
|
||||
<MarkdownRenderBoundary
|
||||
key={`markdown-${index}`}
|
||||
resetKey={`${segment.content.length}:${segment.content.slice(-32)}`}
|
||||
>
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
mermaidConfig={streamdownPlugins.mermaidConfig}
|
||||
components={components}
|
||||
>
|
||||
{segment.content}
|
||||
</MessageResponse>
|
||||
</MarkdownRenderBoundary>
|
||||
)
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,20 +33,20 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
isAutoPreviewableArtifactPath,
|
||||
languageOfStreamingArtifact,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
namedStreamingWriteFilePath,
|
||||
} from "@/core/artifacts/streaming-write-preview";
|
||||
import { listStreamingWriteFileCalls } from "@/core/artifacts/parse-partial-json";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
normalizeSearchResults,
|
||||
@@ -57,9 +57,12 @@ import {
|
||||
extractReasoningContentFromMessage,
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { collectRetrievedFiles } from "@/core/messages/retrieved-files";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { isInternalToolCallName } from "@/core/tools/visibility";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
buildSkillDisplayMap,
|
||||
@@ -82,7 +85,8 @@ import { Tooltip } from "../tooltip";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { SingleRoundRagCitationDialog } from "./message-list-item";
|
||||
import { SingleRoundRagCitationDrawer } from "./message-list-item";
|
||||
import { RetrievedFileList } from "./retrieved-file-list";
|
||||
|
||||
export function MessageGroup({
|
||||
className,
|
||||
@@ -90,14 +94,17 @@ export function MessageGroup({
|
||||
isLoading = false,
|
||||
displaySources = [],
|
||||
ragModeEnabled = false,
|
||||
threadId,
|
||||
}: {
|
||||
className?: string;
|
||||
messages: Message[];
|
||||
isLoading?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
threadId?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const [showAbove, setShowAbove] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
@@ -113,6 +120,12 @@ export function MessageGroup({
|
||||
[messages, writingModeEnabled],
|
||||
);
|
||||
const { skills } = useSkills();
|
||||
const retrievedFiles = useMemo(
|
||||
() => citationDisplay?.retrieved_files_enabled === true
|
||||
? collectRetrievedFiles(messages, new Set(skills.map((skill) => skill.name)))
|
||||
: [],
|
||||
[messages, skills, citationDisplay?.retrieved_files_enabled],
|
||||
);
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
@@ -210,6 +223,7 @@ export function MessageGroup({
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
displaySources={displaySources}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
@@ -222,6 +236,7 @@ export function MessageGroup({
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
displaySources={displaySources}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
@@ -271,7 +286,8 @@ export function MessageGroup({
|
||||
</>
|
||||
)}
|
||||
</ChainOfThought>
|
||||
<SingleRoundRagCitationDialog
|
||||
{threadId && <RetrievedFileList files={retrievedFiles} threadId={threadId} />}
|
||||
<SingleRoundRagCitationDrawer
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedCitation(null);
|
||||
@@ -336,6 +352,7 @@ function ToolCall({
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
skillDisplayMap = new Map(),
|
||||
displaySources = [],
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
@@ -346,6 +363,7 @@ function ToolCall({
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
@@ -356,19 +374,42 @@ function ToolCall({
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
? syncDisplaySourceIndexes(
|
||||
rankDisplaySources(
|
||||
normalizeDisplaySources(result, displayMatch.display).map(
|
||||
(item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}),
|
||||
),
|
||||
),
|
||||
displaySources,
|
||||
)
|
||||
: [];
|
||||
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||
const isLlmWikiTool =
|
||||
name === "llmwiki_search" ||
|
||||
name === "llmwiki_read_wiki_page" ||
|
||||
name === "llmwiki_browse_wiki_index";
|
||||
const wikiQuery = isLlmWikiTool
|
||||
? resolveSearchQuery(args, result) ||
|
||||
(name === "llmwiki_read_wiki_page"
|
||||
? normalizeSearchResults(result)[0]?.title ||
|
||||
(typeof args.wiki_slug === "string" ? args.wiki_slug : "")
|
||||
: "")
|
||||
: "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: null;
|
||||
: name === "llmwiki_read_wiki_page"
|
||||
? wikiQuery
|
||||
? `读取 Wiki 页面「${wikiQuery}」`
|
||||
: "读取 Wiki 页面"
|
||||
: name === "llmwiki_browse_wiki_index"
|
||||
? "读取 Wiki 索引与知识库摘要"
|
||||
: null;
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
@@ -394,12 +435,17 @@ function ToolCall({
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
const defaultItems = syncDisplaySourceIndexes(
|
||||
rankDisplaySources(
|
||||
normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
})),
|
||||
),
|
||||
displaySources,
|
||||
);
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
@@ -781,17 +827,10 @@ function ArtifactWriteToolCall({
|
||||
const rawPath = typeof args.path === "string" ? args.path : "";
|
||||
const content = typeof args.content === "string" ? args.content : "";
|
||||
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
const sourcePath = hasUsablePath
|
||||
? normalizedPath
|
||||
: name === "write_file" && content.trim()
|
||||
? provisionalWriteFilePath(content)
|
||||
: null;
|
||||
const sourcePath = namedStreamingWriteFilePath(rawPath);
|
||||
const showInlineContent = name === "write_file" && !sourcePath;
|
||||
const previewKey = messageId && id ? `${messageId}:${id}` : null;
|
||||
// Never select a bare provisional name like `未命名文件.md` — the artifacts
|
||||
// API only serves `/mnt/user-data/...` paths and would 400. Wait until the
|
||||
// tool call has ids so we can open a write-file: memory preview instead.
|
||||
// Until a real path arrives, both auto-open and manual clicks stay inline.
|
||||
const artifactPath =
|
||||
sourcePath && name === "write_file" && messageId && id
|
||||
? buildWriteFileVirtualUrl({ path: sourcePath, messageId, toolCallId: id })
|
||||
@@ -832,7 +871,9 @@ function ArtifactWriteToolCall({
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{(normalizedPath || sourcePath) && (
|
||||
{showInlineContent ? (
|
||||
content && <InlineWritePreview content={content} />
|
||||
) : (normalizedPath || sourcePath) && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{artifactDisplayName(normalizedPath || (sourcePath ?? ""))}
|
||||
</ChainOfThoughtSearchResult>
|
||||
@@ -841,6 +882,28 @@ function ArtifactWriteToolCall({
|
||||
);
|
||||
}
|
||||
|
||||
function InlineWritePreview({ content }: { content: string }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const follow = useRef(true);
|
||||
useLayoutEffect(() => {
|
||||
if (follow.current && ref.current) {
|
||||
ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}
|
||||
}, [content]);
|
||||
return (
|
||||
<pre
|
||||
ref={ref}
|
||||
onScroll={(event) => {
|
||||
const node = event.currentTarget;
|
||||
follow.current = node.scrollHeight - node.scrollTop - node.clientHeight < 24;
|
||||
}}
|
||||
className="border-border m-0 max-h-64 overflow-auto border-l pl-3 font-mono text-xs leading-6 whitespace-pre-wrap break-words text-emerald-600 dark:text-emerald-400"
|
||||
>
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
interface GenericCoTStep<T extends string = string> {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
@@ -861,6 +924,10 @@ type CoTStep = CoTReasoningStep | CoTToolCallStep;
|
||||
|
||||
function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
const steps: CoTStep[] = [];
|
||||
// Some streaming providers omit tool_call_id on ToolMessages. Keep a
|
||||
// per-name cursor so parallel searches consume distinct unbound results in
|
||||
// order instead of every step reusing the first page.
|
||||
const toolCallNameFallbackIndexes = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
if (message.type === "ai") {
|
||||
const reasoning = extractReasoningContentFromMessage(message);
|
||||
@@ -873,14 +940,37 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
};
|
||||
steps.push(step);
|
||||
}
|
||||
for (const tool_call of message.tool_calls ?? []) {
|
||||
if (tool_call.name === "task") {
|
||||
const writes = new Map(
|
||||
listStreamingWriteFileCalls(message).map((write) => [write.id, write]),
|
||||
);
|
||||
const chunks = (message as Parameters<typeof listStreamingWriteFileCalls>[0])
|
||||
.tool_call_chunks;
|
||||
const toolCalls = [...(message.tool_calls ?? [])];
|
||||
for (const write of writes.values()) {
|
||||
if (
|
||||
!toolCalls.some((call) => call.id === write.id) &&
|
||||
chunks?.some(
|
||||
(chunk) => chunk.id === write.id && chunk.name === "write_file",
|
||||
)
|
||||
) {
|
||||
toolCalls.push({
|
||||
id: write.id,
|
||||
name: "write_file",
|
||||
args: { path: write.path, content: write.content },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const tool_call of toolCalls) {
|
||||
if (tool_call.name === "task" || isInternalToolCallName(tool_call.name)) {
|
||||
continue;
|
||||
}
|
||||
const writePath =
|
||||
typeof (tool_call.args as { path?: unknown } | undefined)?.path === "string"
|
||||
? (tool_call.args as { path: string }).path
|
||||
: "";
|
||||
const write = tool_call.name === "write_file" && tool_call.id
|
||||
? writes.get(tool_call.id)
|
||||
: undefined;
|
||||
const args = write
|
||||
? { ...tool_call.args, path: write.path, content: write.content }
|
||||
: tool_call.args;
|
||||
const writePath = typeof args?.path === "string" ? args.path : "";
|
||||
if (
|
||||
(
|
||||
tool_call.name === "write_file" ||
|
||||
@@ -898,15 +988,27 @@ function convertToSteps(messages: Message[], writingMode: boolean): CoTStep[] {
|
||||
messageId: message.id,
|
||||
type: "toolCall",
|
||||
name: tool_call.name,
|
||||
args: tool_call.args,
|
||||
args,
|
||||
};
|
||||
const toolCallId = tool_call.id;
|
||||
if (toolCallId) {
|
||||
const toolCallResult = findToolCallResult(
|
||||
let toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
{ allowNameFallback: false },
|
||||
);
|
||||
if (toolCallResult === undefined) {
|
||||
const nameKey = tool_call.name.toLowerCase();
|
||||
const fallbackIndex = toolCallNameFallbackIndexes.get(nameKey) ?? 0;
|
||||
toolCallResult = findToolCallResult(
|
||||
toolCallId,
|
||||
messages,
|
||||
tool_call.name,
|
||||
{ allowNameFallback: true, nameFallbackIndex: fallbackIndex },
|
||||
);
|
||||
toolCallNameFallbackIndexes.set(nameKey, fallbackIndex + 1);
|
||||
}
|
||||
if (toolCallResult !== undefined) {
|
||||
if (typeof toolCallResult === "string") {
|
||||
try {
|
||||
@@ -973,6 +1075,48 @@ function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function rankDisplaySources(
|
||||
sources: RuntimeDisplaySource[],
|
||||
): RuntimeDisplaySource[] {
|
||||
return sources
|
||||
.map((source, originalOrder) => ({ source, originalOrder }))
|
||||
.sort((left, right) => {
|
||||
const leftScore =
|
||||
typeof left.source.score === "number" && Number.isFinite(left.source.score)
|
||||
? left.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const rightScore =
|
||||
typeof right.source.score === "number" && Number.isFinite(right.source.score)
|
||||
? right.source.score
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
return rightScore - leftScore || left.originalOrder - right.originalOrder;
|
||||
})
|
||||
.map(({ source }, index) => ({ ...source, index: index + 1 }));
|
||||
}
|
||||
|
||||
function syncDisplaySourceIndexes(
|
||||
sources: RuntimeDisplaySource[],
|
||||
globalSources: RuntimeDisplaySource[],
|
||||
): RuntimeDisplaySource[] {
|
||||
const indexByCitationId = new Map(
|
||||
globalSources.flatMap((source) => {
|
||||
const citationId =
|
||||
typeof source.raw?.citation_id === "string"
|
||||
? source.raw.citation_id.trim()
|
||||
: "";
|
||||
return citationId ? [[citationId, source.index] as const] : [];
|
||||
}),
|
||||
);
|
||||
return sources.map((source) => {
|
||||
const citationId =
|
||||
typeof source.raw?.citation_id === "string"
|
||||
? source.raw.citation_id.trim()
|
||||
: "";
|
||||
const index = citationId ? indexByCitationId.get(citationId) : undefined;
|
||||
return index ? { ...source, index } : source;
|
||||
});
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
@@ -1010,32 +1154,46 @@ function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="pr-6 text-base leading-relaxed">
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
||||
<SheetTitle className="text-base leading-relaxed">
|
||||
{item.title ?? "检索结果"}
|
||||
</DialogTitle>
|
||||
</SheetTitle>
|
||||
{(sourceLabel || date) && (
|
||||
<DialogDescription className="flex items-center gap-2 text-xs">
|
||||
<SheetDescription className="flex items-center gap-2 text-xs">
|
||||
{sourceLabel && <span>{sourceLabel}</span>}
|
||||
{sourceLabel && date && <span>·</span>}
|
||||
{date && <span>{date}</span>}
|
||||
</DialogDescription>
|
||||
</SheetDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{content && (
|
||||
<div className="text-foreground max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{content}
|
||||
{!sourceLabel && !date && (
|
||||
<SheetDescription className="sr-only">检索结果详情</SheetDescription>
|
||||
)}
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{content ? (
|
||||
<article className="text-foreground rounded-xl border bg-background px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
|
||||
{content}
|
||||
</article>
|
||||
) : (
|
||||
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
|
||||
暂无可展示的检索内容
|
||||
</div>
|
||||
)}
|
||||
{item.url && (
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||
{item.url}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{item.url && (
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||
{item.url}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Message } from '@langchain/langgraph-sdk';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
BookMarkedIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
ExternalLinkIcon,
|
||||
FileIcon,
|
||||
@@ -25,7 +26,6 @@ import { Loader } from '@/components/ai-elements/loader';
|
||||
import {
|
||||
Message as AIElementMessage,
|
||||
MessageContent as AIElementMessageContent,
|
||||
MessageResponse as AIElementMessageResponse,
|
||||
MessageToolbar,
|
||||
} from '@/components/ai-elements/message';
|
||||
import {
|
||||
@@ -37,17 +37,12 @@ import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
UploadedFilePreviewDialog,
|
||||
@@ -58,6 +53,7 @@ import { useChatAskTimes } from '@/core/chat-timestamps';
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { readRuntimeEnv } from '@/env';
|
||||
import { useI18n } from '@/core/i18n/hooks';
|
||||
import { stripKnowledgeRetrievalDiagnostics } from '@/core/llmwiki/answer-display';
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
@@ -71,18 +67,20 @@ import {
|
||||
applyInlineCitations,
|
||||
type RuntimeDisplaySource,
|
||||
} from '@/core/skill-display/runtime';
|
||||
import { humanMessagePlugins } from '@/core/streamdown';
|
||||
import {
|
||||
getMultiRoundRagTrace,
|
||||
isMultiRoundRagMessage,
|
||||
} from '@/core/threads/use-multi-round-rag';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AnswerFeedbackContext } from '@/strategy-components/api/answer-feedback';
|
||||
|
||||
import { CitationLink } from '../citations/citation-link';
|
||||
import { CopyButton } from '../copy-button';
|
||||
import { Tooltip } from '../tooltip';
|
||||
import { SkillDisplayBlock } from '../skill-display-block';
|
||||
import { ThreadContext } from './context';
|
||||
import { AnswerFeedbackControl } from './answer-feedback-control';
|
||||
import { isEligibleAnswerFeedbackMessage } from './answer-feedback-eligibility';
|
||||
import { DeleteMessageButton } from './delete-message-button';
|
||||
import { DownloadWordButton } from './download-word-button';
|
||||
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
||||
@@ -168,6 +166,8 @@ export function MessageListItem({
|
||||
onEditSave,
|
||||
onMessageDeleted,
|
||||
turnTime,
|
||||
answerFeedbackEnabled = false,
|
||||
answerFeedbackContext,
|
||||
}: {
|
||||
className?: string;
|
||||
fitRowToContent?: boolean;
|
||||
@@ -194,6 +194,10 @@ export function MessageListItem({
|
||||
* wall clock at render time and appear to "drift" to now on every reload.
|
||||
*/
|
||||
turnTime?: string;
|
||||
/** Opt-in feedback affordance for completed, persisted assistant answers. */
|
||||
answerFeedbackEnabled?: boolean;
|
||||
/** Authoritative run/agent mapping hydrated in one conversation batch. */
|
||||
answerFeedbackContext?: AnswerFeedbackContext;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isHuman = message.type === 'human';
|
||||
@@ -218,6 +222,9 @@ export function MessageListItem({
|
||||
!!message.id &&
|
||||
!message.id.startsWith('opt-') &&
|
||||
!!messageMarkdown;
|
||||
const canEvaluateAnswer =
|
||||
answerFeedbackEnabled &&
|
||||
isEligibleAnswerFeedbackMessage(message, Boolean(isLoading));
|
||||
const confirmAssistantEdit = useCallback(async () => {
|
||||
const value = assistantDraft.trim();
|
||||
if (!value || !message.id || !onEditSave || assistantSaving) {
|
||||
@@ -316,17 +323,24 @@ export function MessageListItem({
|
||||
{/*
|
||||
Human messages carry their own inline copy/edit toolbar inside
|
||||
MessageContent (above the timestamp). Only assistant answers use the
|
||||
floating bottom toolbar here.
|
||||
always-visible floating bottom toolbar here.
|
||||
*/}
|
||||
{!isLoading && !isHuman && (
|
||||
<MessageToolbar
|
||||
className={cn(
|
||||
'-bottom-8',
|
||||
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
|
||||
'absolute right-0 left-0 z-20',
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<CopyButton clipboardData={messageMarkdown} />
|
||||
{canEvaluateAnswer && message.id && answerFeedbackContext && (
|
||||
<AnswerFeedbackControl
|
||||
threadId={threadId}
|
||||
messageId={message.id}
|
||||
context={answerFeedbackContext}
|
||||
/>
|
||||
)}
|
||||
{messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
@@ -358,7 +372,7 @@ export function MessageListItem({
|
||||
/**
|
||||
* Inline copy/edit affordances + editor for the user's own question bubble.
|
||||
*
|
||||
* Layout: bubble → hover toolbar (copy + edit) → timestamp. The toolbar sits
|
||||
* Layout: bubble → toolbar (copy + edit) → timestamp. The toolbar sits
|
||||
* in normal flow above the timestamp (not floating) so it never covers the
|
||||
* following message. Clicking edit turns the bubble into a textarea seeded with
|
||||
* the question; confirming sends the edited text as a brand-new turn (the
|
||||
@@ -370,7 +384,6 @@ function HumanMessageBlock({
|
||||
pageContext,
|
||||
filesList,
|
||||
messageTime,
|
||||
components,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
deleteButton,
|
||||
@@ -380,7 +393,7 @@ function HumanMessageBlock({
|
||||
pageContext?: EmbeddedPageContext | null;
|
||||
filesList: ReactNode;
|
||||
messageTime: { label: string; full: string } | null;
|
||||
components: MarkdownContentProps["components"];
|
||||
components?: MarkdownContentProps["components"];
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
||||
deleteButton?: ReactNode;
|
||||
@@ -460,14 +473,9 @@ function HumanMessageBlock({
|
||||
}
|
||||
|
||||
const messageResponse = text ? (
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||
components={components}
|
||||
parseIncompleteMarkdown={false}
|
||||
>
|
||||
<div className="whitespace-pre-wrap break-words text-[15px] leading-7">
|
||||
{text}
|
||||
</AIElementMessageResponse>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
@@ -481,7 +489,7 @@ function HumanMessageBlock({
|
||||
<MessageTimeStamp time={messageTime} align="right" />
|
||||
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
|
||||
{(text || deleteButton) && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{text && <CopyButton clipboardData={text} className="size-6" />}
|
||||
{text && (onEditResubmit || onEditSave) && (
|
||||
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
||||
@@ -692,13 +700,16 @@ function MessageContent_({
|
||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||
: '';
|
||||
}
|
||||
const sanitizedContent = stripKnowledgeRetrievalDiagnostics(rawContent ?? '');
|
||||
if (isMultiRoundRag) {
|
||||
return stripIncompleteCitationMarker(rawContent ?? '');
|
||||
return stripIncompleteCitationMarker(sanitizedContent);
|
||||
}
|
||||
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
||||
const withCitations = applyInlineCitations(sanitizedContent, displaySources);
|
||||
return stripIncompleteCitationMarker(withCitations);
|
||||
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
||||
const markdownRehypePlugins = useMemo(
|
||||
const markdownRehypePlugins = useMemo<
|
||||
NonNullable<MarkdownContentProps['rehypePlugins']>
|
||||
>(
|
||||
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
||||
[rehypePlugins],
|
||||
);
|
||||
@@ -808,10 +819,7 @@ function MessageContent_({
|
||||
)}
|
||||
</div>
|
||||
{isSingleRoundRag && displaySources.length > 0 && (
|
||||
<FrontendRagSourcesPanel
|
||||
sources={displaySources}
|
||||
onSelect={setSelectedCitation}
|
||||
/>
|
||||
<FrontendRagSourcesPanel sources={displaySources} />
|
||||
)}
|
||||
<MessageTokenUsage
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -823,7 +831,7 @@ function MessageContent_({
|
||||
) : null}
|
||||
</AIElementMessageContent>
|
||||
{isSingleRoundRag && (
|
||||
<SingleRoundRagCitationDialog
|
||||
<SingleRoundRagCitationDrawer
|
||||
source={selectedCitation}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedCitation(null);
|
||||
@@ -835,109 +843,173 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
/**
|
||||
* 单轮 RAG 专属的「引用来源」折叠面板。
|
||||
*
|
||||
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
|
||||
* 单轮 RAG 的参考文献入口。先打开右侧抽屉查看列表,
|
||||
* 选中条目后在同一抽屉中阅读详情,避免跳出居中弹框打断问答。
|
||||
*/
|
||||
function FrontendRagSourcesPanel({
|
||||
sources,
|
||||
onSelect,
|
||||
}: {
|
||||
sources: RuntimeDisplaySource[];
|
||||
onSelect: (source: RuntimeDisplaySource) => void;
|
||||
}) {
|
||||
function FrontendRagSourcesPanel({ sources }: { sources: RuntimeDisplaySource[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedSource, setSelectedSource] = useState<RuntimeDisplaySource | null>(null);
|
||||
if (sources.length === 0) return null;
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setSelectedSource(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs transition-colors"
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground mt-3 inline-flex items-center gap-1.5 rounded-md py-1 text-xs transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
aria-label={`查看 ${sources.length} 条参考文献`}
|
||||
>
|
||||
<BookMarkedIcon className="size-3.5" />
|
||||
<span className="font-medium">参考文献</span>
|
||||
<span className="text-muted-foreground/70">({sources.length})</span>
|
||||
<span>查看</span>
|
||||
<ChevronRightIcon className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<BookMarkedIcon className="size-3.5" />
|
||||
<span className="font-medium">引用来源</span>
|
||||
<span className="text-muted-foreground/70">({sources.length})</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200',
|
||||
!open && '-rotate-90',
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
{selectedSource && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="mt-0.5 shrink-0"
|
||||
onClick={() => setSelectedSource(null)}
|
||||
aria-label="返回参考文献列表"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="truncate text-base">
|
||||
{selectedSource?.title || "参考文献"}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="mt-1 text-xs">
|
||||
{selectedSource
|
||||
? "引用详情;可返回列表继续查看其他文献。"
|
||||
: `本次回答共引用 ${sources.length} 条资料,点击条目查看具体内容。`}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{selectedSource ? (
|
||||
<CitationSourceDetail source={selectedSource} />
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-5">
|
||||
<div className="grid gap-2">
|
||||
{sources.map((source, idx) => (
|
||||
<button
|
||||
key={source.id ?? `${source.title}-${idx}`}
|
||||
type="button"
|
||||
onClick={() => setSelectedSource(source)}
|
||||
className="group bg-card hover:border-primary/40 hover:bg-accent block w-full cursor-pointer rounded-xl border p-3.5 text-left shadow-sm transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="bg-primary/10 text-primary mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
||||
{source.index}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground line-clamp-2 min-w-0 flex-1 text-sm font-medium">
|
||||
{source.title}
|
||||
</span>
|
||||
{source.type && (
|
||||
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-[11px]">
|
||||
{source.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(source.source || source.time) && (
|
||||
<p className="text-muted-foreground mt-1 text-[11px]">
|
||||
{[source.source, source.time].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{(source.snippet || source.content) && (
|
||||
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{source.snippet || source.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRightIcon className="text-muted-foreground mt-1 size-4 shrink-0 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 grid gap-2">
|
||||
{sources.map((source, idx) => (
|
||||
<button
|
||||
key={source.id ?? `${source.title}-${idx}`}
|
||||
type="button"
|
||||
onClick={() => onSelect(source)}
|
||||
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
||||
{source.index}
|
||||
</span>
|
||||
<span
|
||||
className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
|
||||
title={source.title}
|
||||
>
|
||||
{source.title}
|
||||
</span>
|
||||
{source.type && (
|
||||
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
|
||||
{source.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{source.snippet && (
|
||||
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
||||
{source.snippet}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleRoundRagCitationDialog({
|
||||
export function SingleRoundRagCitationDrawer({
|
||||
source,
|
||||
onOpenChange,
|
||||
}: {
|
||||
source: RuntimeDisplaySource | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
||||
return (
|
||||
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle className="leading-relaxed">
|
||||
<Sheet open={!!source} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
||||
<SheetTitle className="leading-relaxed">
|
||||
{source?.title || "引用资料"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-1 text-xs">
|
||||
</SheetTitle>
|
||||
<SheetDescription className="space-y-1 text-xs">
|
||||
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
|
||||
{source?.time && <span className="block">时间:{source.time}</span>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{source && <CitationSourceDetail source={source} />}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function CitationSourceDetail({ source }: { source: RuntimeDisplaySource }) {
|
||||
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/20 p-4 sm:p-6">
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{sourceUrl && (
|
||||
<a
|
||||
href={sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-input hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
||||
className="border-input bg-background hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
查看原文链接
|
||||
</a>
|
||||
)}
|
||||
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-lg p-3 text-sm leading-7 whitespace-pre-wrap">
|
||||
{source?.content || source?.snippet || "暂无引用内容"}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<article className="bg-background rounded-xl border px-4 py-5 text-sm leading-7 whitespace-pre-wrap shadow-sm sm:px-6">
|
||||
{source.content || source.snippet || "暂无引用内容"}
|
||||
</article>
|
||||
{sourceUrl && (
|
||||
<p className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||
{sourceUrl}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1163,6 +1235,8 @@ function RichFileCard({
|
||||
<UploadedFilePreviewDialog
|
||||
filename={file.filename}
|
||||
fileUrl={fileUrl}
|
||||
threadId={threadId}
|
||||
virtualPath={file.path}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
@@ -40,20 +41,35 @@ import {
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import {
|
||||
shouldRenderUnattachedKnowledgeRetrievalProgress,
|
||||
useKnowledgeRetrievalProgress,
|
||||
} from "@/core/threads/knowledge-retrieval-progress";
|
||||
import {
|
||||
buildSmartKnowledgeRetrievalDisplaySegments,
|
||||
collectSmartKnowledgeRetrievalTurnAssistantMessageIds,
|
||||
hasSmartKnowledgeRetrievalCalls,
|
||||
latestSmartKnowledgeRetrievalMessageId,
|
||||
type SmartKnowledgeRetrievalResultItem,
|
||||
} from "@/core/threads/smart-knowledge-retrieval";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { isMultiRoundRagMessage } from "@/core/threads/use-multi-round-rag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
import { useAnswerFeedbackContexts } from "@/strategy-components/api/answer-feedback";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { getQuestionAnchorId } from "../question-navigator";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
|
||||
import { ReferenceBatchesContext, ThreadContext } from "./context";
|
||||
import { isEligibleAnswerFeedbackMessage } from "./answer-feedback-eligibility";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||
import { DocumentRewriteMessage, DocumentRewriteTimeline } from "./document-rewrite-timeline";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
|
||||
import {
|
||||
MessageMetricsProvider,
|
||||
MessageTokenUsageList,
|
||||
@@ -112,6 +128,50 @@ function ScrollToBottomOnUserMessage({
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the conversation's scroll position to hosts that render controls
|
||||
* outside the scroll container (for example the docked follow-up questions).
|
||||
*/
|
||||
function ConversationBottomObserver({
|
||||
onAtBottomChange,
|
||||
}: {
|
||||
onAtBottomChange?: (isAtBottom: boolean) => void;
|
||||
}) {
|
||||
const { scrollRef, contentRef, escapedFromLock } = useStickToBottomContext();
|
||||
const [isExactlyAtBottom, setIsExactlyAtBottom] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const update = () => {
|
||||
const distance =
|
||||
scrollElement.scrollHeight -
|
||||
scrollElement.scrollTop -
|
||||
scrollElement.clientHeight;
|
||||
setIsExactlyAtBottom(distance <= 2);
|
||||
};
|
||||
update();
|
||||
scrollElement.addEventListener("scroll", update, { passive: true });
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
|
||||
resizeObserver?.observe(scrollElement);
|
||||
if (contentRef.current) resizeObserver?.observe(contentRef.current);
|
||||
return () => {
|
||||
scrollElement.removeEventListener("scroll", update);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [contentRef, scrollRef]);
|
||||
|
||||
const displayControlsAtBottom = isExactlyAtBottom && !escapedFromLock;
|
||||
|
||||
useEffect(() => {
|
||||
onAtBottomChange?.(displayControlsAtBottom);
|
||||
}, [displayControlsAtBottom, onAtBottomChange]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
className,
|
||||
contentClassName,
|
||||
@@ -122,6 +182,7 @@ export function MessageList({
|
||||
tokenUsageEnabled = false,
|
||||
extraRehypePlugins,
|
||||
scrollButtonClassName,
|
||||
onAtBottomChange,
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onEditSave,
|
||||
@@ -141,6 +202,7 @@ export function MessageList({
|
||||
onOpenArtifact,
|
||||
onDownloadArtifact,
|
||||
afterDocumentRewriteItems,
|
||||
answerFeedbackEnabled = false,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
@@ -151,6 +213,8 @@ export function MessageList({
|
||||
tokenUsageEnabled?: boolean;
|
||||
/** 额外定位「回到最新」悬浮按钮——不同页面输入框高度不同,需上移让位。 */
|
||||
scrollButtonClassName?: string;
|
||||
/** Reports whether the message viewport is currently at its bottom edge. */
|
||||
onAtBottomChange?: (isAtBottom: boolean) => void;
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
@@ -196,6 +260,12 @@ export function MessageList({
|
||||
onDownloadArtifact?: (filepath: string) => void | Promise<void>;
|
||||
/** Optional host content placed immediately after document-rewrite timelines. */
|
||||
afterDocumentRewriteItems?: React.ReactNode;
|
||||
/**
|
||||
* Enables per-answer user feedback for persisted assistant replies. Kept
|
||||
* opt-in because MessageList is also reused by read-only previews, setup
|
||||
* flows and virtual business-card feeds.
|
||||
*/
|
||||
answerFeedbackEnabled?: boolean;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
@@ -217,8 +287,13 @@ export function MessageList({
|
||||
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [selectedRetrievalResult, setSelectedRetrievalResult] = useState<{
|
||||
item: SmartKnowledgeRetrievalResultItem;
|
||||
target: WeKnoraSourceTarget;
|
||||
} | null>(null);
|
||||
useEffect(() => {
|
||||
setHiddenMessageIds(new Set());
|
||||
setSelectedRetrievalResult(null);
|
||||
}, [threadId]);
|
||||
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
|
||||
if (deletedIds.length === 0) return;
|
||||
@@ -229,9 +304,20 @@ export function MessageList({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const documentRewriteItems = useMemo(
|
||||
() => documentRewritePresentation?.forThread(threadId) ?? [],
|
||||
[documentRewritePresentation, threadId],
|
||||
);
|
||||
const artifactRewriteItems = useMemo(
|
||||
() => documentRewriteItems.filter(
|
||||
(item) => item.source.kind === "artifact" && item.operation === "rewrite",
|
||||
),
|
||||
[documentRewriteItems],
|
||||
);
|
||||
|
||||
const allMessages = useMemo(() => {
|
||||
const hasOverrides = messageContentOverrides && Object.keys(messageContentOverrides).length > 0;
|
||||
return thread.messages
|
||||
const persistedMessages = thread.messages
|
||||
.filter((m) => !isHiddenFromUIMessage(m))
|
||||
.map((message) => {
|
||||
const override =
|
||||
@@ -242,7 +328,23 @@ export function MessageList({
|
||||
? message
|
||||
: ({ ...message, content: override } as Message);
|
||||
});
|
||||
}, [messageContentOverrides, thread.messages]);
|
||||
const rewriteMessages = artifactRewriteItems.flatMap((item) => [
|
||||
{
|
||||
id: `document-rewrite-input-${item.id}`,
|
||||
type: "human",
|
||||
content: "AI改写",
|
||||
} as unknown as Message,
|
||||
{
|
||||
id: `document-rewrite-${item.id}`,
|
||||
type: "ai",
|
||||
content: "",
|
||||
additional_kwargs: {
|
||||
document_rewrite_presentation_id: item.id,
|
||||
},
|
||||
} as unknown as Message,
|
||||
]);
|
||||
return [...persistedMessages, ...rewriteMessages];
|
||||
}, [artifactRewriteItems, messageContentOverrides, thread.messages]);
|
||||
const messages = useMemo(
|
||||
() => {
|
||||
const externalHidden = externallyHiddenMessageIds ?? new Set<string>();
|
||||
@@ -256,8 +358,56 @@ export function MessageList({
|
||||
},
|
||||
[allMessages, externallyHiddenMessageIds, hiddenMessageIds],
|
||||
);
|
||||
const documentRewriteItems = documentRewritePresentation?.forThread(threadId) ?? [];
|
||||
|
||||
const knowledgeRetrievalProgress = useKnowledgeRetrievalProgress(threadId);
|
||||
const hasHumanMessage = messages.some((message) => message.type === "human");
|
||||
const currentTurnMessages = useMemo(() => {
|
||||
let lastHumanIndex = -1;
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
if (messages[index]?.type === "human") {
|
||||
lastHumanIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return messages.slice(lastHumanIndex + 1);
|
||||
}, [messages]);
|
||||
const latestCurrentSmartMessageId = useMemo(
|
||||
() => latestSmartKnowledgeRetrievalMessageId(currentTurnMessages),
|
||||
[currentTurnMessages],
|
||||
);
|
||||
const lastCurrentTurnMessageId = currentTurnMessages.at(-1)?.id;
|
||||
const feedbackAvailable = answerFeedbackEnabled && allowDelete;
|
||||
const answerFeedbackMessageIds = useMemo(() => {
|
||||
if (!feedbackAvailable) return [];
|
||||
const streamingAssistantId = thread.isLoading
|
||||
? [...messages].reverse().find((message) => message.type === "ai")?.id
|
||||
: undefined;
|
||||
return messages.flatMap((message) => {
|
||||
const kwargs = message.additional_kwargs ?? {};
|
||||
const explicitlyStreaming =
|
||||
kwargs.deep_research_summary_streaming === true ||
|
||||
kwargs.deep_research_follow_up_streaming === true;
|
||||
const loading =
|
||||
explicitlyStreaming ||
|
||||
(Boolean(streamingAssistantId) && message.id === streamingAssistantId);
|
||||
return isEligibleAnswerFeedbackMessage(message, loading) && message.id
|
||||
? [message.id]
|
||||
: [];
|
||||
});
|
||||
}, [feedbackAvailable, messages, thread.isLoading]);
|
||||
const { data: answerFeedbackContexts } = useAnswerFeedbackContexts(
|
||||
threadId,
|
||||
answerFeedbackMessageIds,
|
||||
feedbackAvailable,
|
||||
);
|
||||
const ratableAnswerMessageIds = useMemo(() => {
|
||||
const lastMessageByRun = new Map<string, string>();
|
||||
for (const message of messages) {
|
||||
if (!message.id) continue;
|
||||
const context = answerFeedbackContexts?.[message.id];
|
||||
if (context) lastMessageByRun.set(context.run_id, message.id);
|
||||
}
|
||||
return new Set(lastMessageByRun.values());
|
||||
}, [answerFeedbackContexts, messages]);
|
||||
const askTimes = useChatAskTimes();
|
||||
|
||||
// Stamp a wall-clock time onto every *question* the first time it is seen, in
|
||||
@@ -321,7 +471,8 @@ export function MessageList({
|
||||
const referenceModeEntryEnabled =
|
||||
citationDisplay?.reference_mode_enabled ?? false;
|
||||
const knowledgeReferencesEnabled =
|
||||
threadContext?.knowledgeReferencesEnabled === true;
|
||||
threadContext?.knowledgeReferencesEnabled === true ||
|
||||
!!threadContext?.knowledgeReferences;
|
||||
const ragModeEnabled =
|
||||
(referenceModeEntryEnabled && settings.display.ragModeEnabled) ||
|
||||
isThreadRagModeLocked(threadId) ||
|
||||
@@ -383,10 +534,29 @@ export function MessageList({
|
||||
return map;
|
||||
}, [thread.messages]);
|
||||
|
||||
const smartRetrievalAssistantMessageIds = useMemo(() => {
|
||||
return collectSmartKnowledgeRetrievalTurnAssistantMessageIds(messages);
|
||||
}, [messages]);
|
||||
|
||||
const resolveDisplaySources = useCallback(
|
||||
(msg: Message | undefined): RuntimeDisplaySource[] => {
|
||||
if (!referencesEnabled || !msg || msg.type !== "ai") return [];
|
||||
if (isMultiRoundRagMessage(msg)) return [];
|
||||
const previousHumanId = findPreviousHumanMessageId(messages, msg.id);
|
||||
// Smart retrieval has its own clickable evidence trace and explicitly
|
||||
// does not require numbered references. Do not feed the same transport
|
||||
// results into the legacy citation layer, which would append [1][2] to
|
||||
// an otherwise complete answer.
|
||||
const isSmartRetrievalTurn = msg.id
|
||||
? smartRetrievalAssistantMessageIds.has(msg.id)
|
||||
: hasSmartKnowledgeRetrievalCalls(
|
||||
messages.slice(
|
||||
previousHumanId
|
||||
? messages.findIndex((message) => message.id === previousHumanId) + 1
|
||||
: 0,
|
||||
),
|
||||
);
|
||||
if (isSmartRetrievalTurn) return [];
|
||||
const batchSources =
|
||||
referenceBatches.find((batch) => batch.assistant_message_id === msg.id)
|
||||
?.sources ?? [];
|
||||
@@ -396,7 +566,7 @@ export function MessageList({
|
||||
if (batchSources.length > 0) return batchSources;
|
||||
if (frontendRagSources.length > 0) return frontendRagSources;
|
||||
return collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
afterMessageId: previousHumanId,
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
});
|
||||
@@ -407,6 +577,7 @@ export function MessageList({
|
||||
referenceBatches,
|
||||
referencesEnabled,
|
||||
skillDisplayMap,
|
||||
smartRetrievalAssistantMessageIds,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -524,6 +695,7 @@ export function MessageList({
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||
<ConversationBottomObserver onAtBottomChange={onAtBottomChange} />
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg)",
|
||||
@@ -533,13 +705,7 @@ export function MessageList({
|
||||
>
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{(() => {
|
||||
const rewriteItemsAboveFile = documentRewriteItems.filter(
|
||||
(item) => item.operation !== "generate",
|
||||
);
|
||||
const rewriteItemsBelowFile = documentRewriteItems.filter(
|
||||
(item) => item.operation === "generate",
|
||||
);
|
||||
let didRenderRewriteAboveFile = false;
|
||||
let didRenderLiveKnowledgeRetrieval = false;
|
||||
const grouped = groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
const items = group.messages.map((msg) => {
|
||||
@@ -585,6 +751,12 @@ export function MessageList({
|
||||
allowDelete ? handleMessageDeleted : undefined
|
||||
}
|
||||
turnTime={msg.id ? turnTimes.get(msg.id) : undefined}
|
||||
answerFeedbackEnabled={answerFeedbackEnabled}
|
||||
answerFeedbackContext={
|
||||
msg.id && ratableAnswerMessageIds.has(msg.id)
|
||||
? answerFeedbackContexts?.[msg.id]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -719,6 +891,21 @@ export function MessageList({
|
||||
{businessCardSlot(message)}
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:document-rewrite") {
|
||||
const presentationId = group.messages[0]?.additional_kwargs?.document_rewrite_presentation_id;
|
||||
const item = typeof presentationId === "string"
|
||||
? artifactRewriteItems.find((candidate) => candidate.id === presentationId)
|
||||
: undefined;
|
||||
return item ? (
|
||||
<div className="w-full" key={group.id}>
|
||||
<DocumentRewriteMessage
|
||||
item={item}
|
||||
onDownloadArtifact={onDownloadArtifact}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
@@ -727,15 +914,8 @@ export function MessageList({
|
||||
files.push(...presentFiles);
|
||||
}
|
||||
}
|
||||
const rewriteAbove = !didRenderRewriteAboveFile
|
||||
? rewriteItemsAboveFile
|
||||
: [];
|
||||
didRenderRewriteAboveFile = true;
|
||||
return (
|
||||
<div className="w-full" key={group.id}>
|
||||
{rewriteAbove.map((item) => (
|
||||
<DocumentRewriteTimeline key={`rewrite-above-${item.id}`} item={item} />
|
||||
))}
|
||||
{group.messages[0] && hasContent(group.messages[0]) && (
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(group.messages[0])}
|
||||
@@ -815,6 +995,7 @@ export function MessageList({
|
||||
results.push(
|
||||
<MessageGroup
|
||||
key={"thinking-group-" + message.id}
|
||||
threadId={threadId}
|
||||
messages={[message]}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(message)}
|
||||
@@ -865,14 +1046,95 @@ export function MessageList({
|
||||
const processingAi = [...group.messages]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
const hasSmartRetrievalCalls = hasSmartKnowledgeRetrievalCalls(
|
||||
group.messages,
|
||||
);
|
||||
const isCurrentTurnRetrievalGroup = group.messages.some(
|
||||
(message) =>
|
||||
message.id ===
|
||||
(latestCurrentSmartMessageId ?? lastCurrentTurnMessageId),
|
||||
);
|
||||
const receivesLiveRetrievalProgress =
|
||||
knowledgeRetrievalProgress.length > 0 &&
|
||||
isCurrentTurnRetrievalGroup;
|
||||
const retrievalDisplaySegments =
|
||||
hasSmartRetrievalCalls || receivesLiveRetrievalProgress
|
||||
? buildSmartKnowledgeRetrievalDisplaySegments(
|
||||
group.messages,
|
||||
receivesLiveRetrievalProgress
|
||||
? knowledgeRetrievalProgress
|
||||
: [],
|
||||
)
|
||||
: [
|
||||
{
|
||||
kind: "processing" as const,
|
||||
id: `processing-${group.id ?? "group"}`,
|
||||
messages: group.messages,
|
||||
},
|
||||
];
|
||||
if (
|
||||
retrievalDisplaySegments.some(
|
||||
(segment) =>
|
||||
segment.kind === "retrieval" &&
|
||||
segment.progressFrames.length > 0,
|
||||
)
|
||||
) {
|
||||
didRenderLiveKnowledgeRetrieval = true;
|
||||
}
|
||||
const processingBody = (
|
||||
<div className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(processingAi)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
<div className="w-full space-y-2">
|
||||
{retrievalDisplaySegments.map((segment) => {
|
||||
if (segment.kind === "retrieval") {
|
||||
return (
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
key={segment.id}
|
||||
messages={segment.messages}
|
||||
progressFrames={segment.progressFrames}
|
||||
dedupeScopeMessages={group.messages}
|
||||
dedupeScopeProgressFrames={
|
||||
receivesLiveRetrievalProgress
|
||||
? knowledgeRetrievalProgress
|
||||
: []
|
||||
}
|
||||
roundOffset={segment.roundOffset}
|
||||
// The AI tool-call message can arrive before its custom
|
||||
// `started` frame. Keep the current turn active through
|
||||
// that short gap instead of misclassifying the pending
|
||||
// call as an interrupted historical action.
|
||||
isLoading={
|
||||
isCurrentTurnRetrievalGroup && thread.isLoading
|
||||
}
|
||||
onOpenResult={(item, target) =>
|
||||
setSelectedRetrievalResult({ item, target })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const segmentHasVisibleProcessing = segment.messages.some(
|
||||
(message) =>
|
||||
message.type === "tool" ||
|
||||
(message.type === "ai" &&
|
||||
(hasReasoning(message) ||
|
||||
hasContent(message) ||
|
||||
hasToolCalls(message))),
|
||||
);
|
||||
if (!segmentHasVisibleProcessing) return null;
|
||||
const segmentAi = [...segment.messages]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
return (
|
||||
<MessageGroup
|
||||
key={segment.id}
|
||||
threadId={threadId}
|
||||
messages={segment.messages}
|
||||
isLoading={thread.isLoading}
|
||||
displaySources={resolveDisplaySources(
|
||||
segmentAi ?? processingAi,
|
||||
)}
|
||||
ragModeEnabled={ragModeEnabled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
@@ -886,12 +1148,27 @@ export function MessageList({
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const leftoverRewriteItems = didRenderRewriteAboveFile
|
||||
? rewriteItemsBelowFile
|
||||
: documentRewriteItems;
|
||||
const leftoverRewriteItems = documentRewriteItems.filter(
|
||||
(item) => item.source.kind !== "artifact" || item.operation !== "rewrite",
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{grouped}
|
||||
{shouldRenderUnattachedKnowledgeRetrievalProgress({
|
||||
frameCount: knowledgeRetrievalProgress.length,
|
||||
isLoading: thread.isLoading,
|
||||
hasHumanMessage,
|
||||
}) &&
|
||||
!didRenderLiveKnowledgeRetrieval && (
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={knowledgeRetrievalProgress}
|
||||
isLoading={thread.isLoading}
|
||||
onOpenResult={(item, target) =>
|
||||
setSelectedRetrievalResult({ item, target })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{leftoverRewriteItems.map((item) => (
|
||||
<DocumentRewriteTimeline key={item.id} item={item} />
|
||||
))}
|
||||
@@ -913,6 +1190,16 @@ export function MessageList({
|
||||
aria-label="回到最新"
|
||||
title="回到最新"
|
||||
/>
|
||||
<WeKnoraSourceDrawer
|
||||
open={selectedRetrievalResult !== null}
|
||||
target={selectedRetrievalResult?.target ?? null}
|
||||
title={selectedRetrievalResult?.item.title}
|
||||
quote={selectedRetrievalResult?.item.preview ?? ""}
|
||||
collapseRetrievedExcerptByDefault
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedRetrievalResult(null);
|
||||
}}
|
||||
/>
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
@@ -957,6 +1244,7 @@ export type SetupWritingApprovalPayload = {
|
||||
};
|
||||
|
||||
export type SetupReportStructureApprovalPayload = {
|
||||
kind?: "report_structure" | "gongwen_template";
|
||||
title?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
@@ -1376,6 +1664,7 @@ function SetupReportStructureApprovalCard({
|
||||
}
|
||||
|
||||
const approvalPayload = payload;
|
||||
const isGongwenTemplate = approvalPayload.kind === "gongwen_template";
|
||||
const busy = disabled || submitting !== null;
|
||||
const directions = approvalPayload.retrieval_directions ?? [];
|
||||
const skills = approvalPayload.retrieval_skills ?? [];
|
||||
@@ -1400,8 +1689,8 @@ function SetupReportStructureApprovalCard({
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs font-medium">报告结构建议</div>
|
||||
<div className="text-sm font-medium">确认后将应用到右侧表单,再点保存入库。</div>
|
||||
<div className="text-muted-foreground text-xs font-medium">{isGongwenTemplate ? "公文模板结构建议" : "报告结构建议"}</div>
|
||||
<div className="text-sm font-medium">{isGongwenTemplate ? "确认后将应用到右侧模板表单,再保存到模板中心。" : "确认后将应用到右侧表单,再点保存入库。"}</div>
|
||||
</div>
|
||||
|
||||
{approvalPayload.title ? (
|
||||
@@ -1443,7 +1732,7 @@ function SetupReportStructureApprovalCard({
|
||||
|
||||
{approvalPayload.content ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">报告大纲</div>
|
||||
<div className="text-muted-foreground text-xs">{isGongwenTemplate ? "模板正文结构" : "报告大纲"}</div>
|
||||
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||||
{approvalPayload.content}
|
||||
</pre>
|
||||
|
||||
@@ -74,6 +74,18 @@ export function SpeedLegend({
|
||||
}
|
||||
|
||||
export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
||||
if (metric.isPaused) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
title="本段生成停顿,窗口内无新 token"
|
||||
>
|
||||
<GaugeIcon className="text-muted-foreground size-3" />
|
||||
<span className="text-muted-foreground">暂停</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
||||
const { color, label } = classifySpeed(metric.tokensPerSec);
|
||||
const display =
|
||||
@@ -85,7 +97,7 @@ export function SpeedPill({ metric }: { metric: MessageMetric }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
|
||||
title={`${label} · ${metric.fromUsage ? "按本段 output_tokens / 本段生成时长" : "字符估算 ≈ 2 字符 / token"}`}
|
||||
>
|
||||
<GaugeIcon className="size-3" style={{ color }} />
|
||||
<span className="font-mono tabular-nums" style={{ color }}>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { ChevronDownIcon, DownloadIcon, EyeIcon, Loader2Icon } from "lucide-react";
|
||||
import { useContext, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { downloadArtifactFile } from "@/core/artifacts/download";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import type { RetrievedFile } from "@/core/messages/retrieved-files";
|
||||
import { getFileIcon } from "@/core/utils/files";
|
||||
import {
|
||||
getLlmWikiDocument,
|
||||
getLlmWikiDocumentPreview,
|
||||
getLlmWikiSourceContext,
|
||||
resolveLlmWikiSourceKnowledgeBase,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
|
||||
import { ThreadContext } from "./context";
|
||||
import { UploadedFilePreviewDialog } from "./uploaded-file-preview-dialog";
|
||||
|
||||
// File links belong to the upstream service. Never attach DeerFlow auth headers.
|
||||
const fetchExternalFile: typeof fetch = (input, init) =>
|
||||
fetch(input, { ...init, credentials: "omit" });
|
||||
|
||||
export function RetrievedFileList({ files, threadId }: { files: RetrievedFile[]; threadId: string }) {
|
||||
const { isMock, publicShareCode } = useContext(ThreadContext) ?? {};
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visible = files.filter((file) => !(publicShareCode && file.kind === "knowledge"));
|
||||
// Results arrive in retrieval order; keep the newest file outside the fold.
|
||||
const latest = visible.at(-1);
|
||||
if (!latest) return null;
|
||||
const older = visible.slice(0, -1).reverse();
|
||||
return (
|
||||
<Collapsible asChild open={expanded} onOpenChange={setExpanded}>
|
||||
<section aria-label="检索到的文件" className="not-prose mt-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-muted-foreground text-xs">检索到的文件 · {visible.length} 个</div>
|
||||
{older.length > 0 && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" size="sm" variant="ghost" className="h-7 gap-1 px-2 text-xs">
|
||||
{expanded ? "收起" : "展开"}
|
||||
<ChevronDownIcon className={`size-3.5 transition-transform ${expanded ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</div>
|
||||
<ul className="grid gap-2">
|
||||
<RetrievedFileCard
|
||||
key={latest.key}
|
||||
file={latest}
|
||||
threadId={threadId}
|
||||
isMock={isMock}
|
||||
publicShareCode={publicShareCode}
|
||||
/>
|
||||
</ul>
|
||||
<CollapsibleContent>
|
||||
<ul className="grid gap-2">
|
||||
{older.map((file) => (
|
||||
<RetrievedFileCard
|
||||
key={file.key}
|
||||
file={file}
|
||||
threadId={threadId}
|
||||
isMock={isMock}
|
||||
publicShareCode={publicShareCode}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function RetrievedFileCard({ file, threadId, isMock, publicShareCode }: {
|
||||
file: RetrievedFile;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [preparingPreview, setPreparingPreview] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [knowledgeTarget, setKnowledgeTarget] = useState<WeKnoraSourceTarget | null>(null);
|
||||
const fileUrl = file.kind === "artifact"
|
||||
? urlOfArtifact({ filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode })
|
||||
: file.kind === "external" ? file.url : "";
|
||||
|
||||
const resolveKnowledgeTarget = async () => {
|
||||
if (file.kind !== "knowledge") throw new Error("当前文件不是知识库文档");
|
||||
if (knowledgeTarget) return knowledgeTarget;
|
||||
const knowledgeBaseId = await resolveLlmWikiSourceKnowledgeBase(file.target.knowledgeBaseId);
|
||||
const target = { ...file.target, knowledgeBaseId };
|
||||
setKnowledgeTarget(target);
|
||||
return target;
|
||||
};
|
||||
|
||||
const preview = async () => {
|
||||
if (preparingPreview) return;
|
||||
setPreparingPreview(true);
|
||||
try {
|
||||
if (file.kind === "knowledge") await resolveKnowledgeTarget();
|
||||
setPreviewOpen(true);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "文件预览失败");
|
||||
} finally {
|
||||
setPreparingPreview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
if (downloading || file.kind === "external") return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
if (file.kind === "artifact") {
|
||||
await downloadArtifactFile({
|
||||
filepath: file.path.split("/").map(encodeURIComponent).join("/"), threadId, isMock, publicShareCode,
|
||||
});
|
||||
} else {
|
||||
const target = await resolveKnowledgeTarget();
|
||||
let documentId = target.documentId;
|
||||
if (!documentId && target.chunkId) {
|
||||
const context = await getLlmWikiSourceContext(target.knowledgeBaseId, target.chunkId);
|
||||
documentId = context.document_id || undefined;
|
||||
}
|
||||
if (!documentId) throw new Error("当前检索结果没有可下载的文件原件,请先查看预览");
|
||||
const [blob, document] = await Promise.all([
|
||||
getLlmWikiDocumentPreview(target.knowledgeBaseId, documentId),
|
||||
getLlmWikiDocument(target.knowledgeBaseId, documentId).catch(() => null),
|
||||
]);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = window.document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = document?.file_name || file.name;
|
||||
anchor.rel = "noopener";
|
||||
window.document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "文件下载失败");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="bg-card flex flex-wrap items-center gap-3 rounded-lg border p-3">
|
||||
{getFileIcon(file.name, "text-muted-foreground size-5 shrink-0")}
|
||||
<div className="min-w-0 flex-1 basis-40">
|
||||
<div className="text-sm font-medium break-words">{file.name}</div>
|
||||
{file.source && <div className="text-muted-foreground mt-0.5 truncate text-xs">{file.source}</div>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{file.kind === "external" && file.previewUrl ? (
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<a href={file.previewUrl} target="_blank" rel="noopener noreferrer">
|
||||
<EyeIcon className="size-4" />预览
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" variant="ghost" disabled={preparingPreview} onClick={() => void preview()}>
|
||||
{preparingPreview ? <Loader2Icon className="size-4 animate-spin" /> : <EyeIcon className="size-4" />}预览
|
||||
</Button>
|
||||
)}
|
||||
{file.kind === "external" ? (
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<a href={file.url} download={file.name} target="_blank" rel="noopener noreferrer">
|
||||
<DownloadIcon className="size-4" />下载
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" variant="ghost" disabled={downloading} onClick={() => void download()}>
|
||||
{downloading ? <Loader2Icon className="size-4 animate-spin" /> : <DownloadIcon className="size-4" />}下载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{previewOpen && (file.kind === "knowledge" ? (
|
||||
<WeKnoraSourceDrawer
|
||||
open
|
||||
target={knowledgeTarget || file.target}
|
||||
title={file.name}
|
||||
quote={file.quote || ""}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
) : (
|
||||
<UploadedFilePreviewDialog
|
||||
filename={file.name}
|
||||
fileUrl={fileUrl}
|
||||
open
|
||||
onOpenChange={setPreviewOpen}
|
||||
threadId={file.kind === "artifact" && !publicShareCode ? threadId : undefined}
|
||||
virtualPath={file.kind === "artifact" ? file.path : undefined}
|
||||
requestFile={file.kind === "external" ? fetchExternalFile : undefined}
|
||||
onDownload={file.kind === "artifact" ? download : undefined}
|
||||
downloading={downloading}
|
||||
/>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import type { KnowledgeRetrievalProgressFrame } from "@/core/threads/smart-knowledge-retrieval";
|
||||
import { SmartKnowledgeRetrievalTrace } from "./smart-knowledge-retrieval-trace";
|
||||
|
||||
const completedFrames: KnowledgeRetrievalProgressFrame[] = [
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "knowledge_search",
|
||||
status: "started",
|
||||
queries: ["成都世运会"],
|
||||
},
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "knowledge_search",
|
||||
status: "completed",
|
||||
result_count: 3,
|
||||
elapsed_ms: 420,
|
||||
},
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "wiki_search",
|
||||
status: "started",
|
||||
pattern: "火炬|开幕|闭幕",
|
||||
},
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "wiki_search",
|
||||
status: "completed",
|
||||
result_count: 4,
|
||||
elapsed_ms: 1380,
|
||||
},
|
||||
];
|
||||
|
||||
test("completed and replayed retrieval traces start collapsed with their hit summary visible", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={completedFrames}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /aria-label="展开智能检索过程"/);
|
||||
assert.match(html, /aria-expanded="false"/);
|
||||
assert.match(html, /调用知识库 2 次 · 原始文档命中 3 条 · Wiki 命中 4 条/);
|
||||
assert.match(html, /累计耗时 1\.8 秒/);
|
||||
assert.doesNotMatch(html, /共\s*\d+\s*轮|第\s*\d+\s*轮检索/);
|
||||
});
|
||||
|
||||
test("keeps a current pending tool call in loading state before its started frame arrives", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "ai",
|
||||
id: "ai-pending-wiki",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "wiki-pending",
|
||||
name: "ordinary_wiki_search",
|
||||
args: { pattern: "数字中国|数据要素" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as Message[];
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={messages}
|
||||
progressFrames={[]}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /检索中/);
|
||||
assert.match(html, /正在查 Wiki/);
|
||||
assert.doesNotMatch(html, /部分步骤失败|部分步骤未完成|已中止/);
|
||||
});
|
||||
|
||||
test("labels and normalizes a visible model-authored regex", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[
|
||||
{
|
||||
type: "ai",
|
||||
id: "ai-regex",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "wiki-regex",
|
||||
name: "ordinary_wiki_search",
|
||||
args: { pattern: "^# [一-龥]{2,10}篇�" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as Message[]}
|
||||
progressFrames={[]}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /正则:\^# \\p\{Han\}\{2,10\}篇/);
|
||||
assert.doesNotMatch(html, /一-龥|�/);
|
||||
});
|
||||
|
||||
test("presents an interrupted historical action as neutral instead of failed", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "ai",
|
||||
id: "ai-interrupted-wiki",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "wiki-interrupted",
|
||||
name: "ordinary_wiki_search",
|
||||
args: { pattern: "数字中国" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as Message[];
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={messages}
|
||||
progressFrames={[]}
|
||||
isLoading={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /检索已结束(部分步骤未完成)/);
|
||||
assert.doesNotMatch(html, /部分步骤失败|部分步骤异常/);
|
||||
});
|
||||
|
||||
test("an in-flight retrieval stays expanded and does not expose a collapse control", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[completedFrames[0]!]}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /data-state="open"/);
|
||||
assert.match(html, /检索中/);
|
||||
assert.match(html, /成都世运会/);
|
||||
assert.doesNotMatch(html, /收起智能检索过程|展开智能检索过程/);
|
||||
});
|
||||
|
||||
test("completed retrieval can be collapsed while the final answer is still streaming", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={completedFrames}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /data-state="open"/);
|
||||
assert.match(html, /检索完成/);
|
||||
assert.match(html, /aria-label="收起智能检索过程"/);
|
||||
assert.match(html, /检索 Wiki/);
|
||||
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
|
||||
assert.doesNotMatch(html, /检索中/);
|
||||
});
|
||||
|
||||
test("does not expose internal round numbering when retrieval cards are interleaved with skills", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[completedFrames[0]!]}
|
||||
isLoading
|
||||
roundOffset={2}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /检索原始文档/);
|
||||
assert.doesNotMatch(html, /第\s*\d+\s*轮检索/);
|
||||
});
|
||||
|
||||
test("renders stable retrieval results as right-drawer controls", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "wiki_search",
|
||||
status: "completed",
|
||||
result_count: 1,
|
||||
result_items: [
|
||||
{
|
||||
kind: "wiki",
|
||||
knowledge_base_id: "kb-news",
|
||||
slug: "news/opening",
|
||||
title: "开幕新闻 Wiki",
|
||||
preview: "页面摘要",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
isLoading
|
||||
onOpenResult={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /data-retrieval-result-detail="true"/);
|
||||
assert.match(html, /在右侧查看:开幕新闻 Wiki/);
|
||||
assert.doesNotMatch(html, /data-retrieval-result-summary="true"/);
|
||||
});
|
||||
|
||||
test("renders a Wiki read as a preview card with elapsed time and right-drawer entry", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "wiki_read_page",
|
||||
status: "completed",
|
||||
result_count: 1,
|
||||
knowledge_base_count: 1,
|
||||
coverage: [
|
||||
{ knowledge_base: "数字中国政策目录智能检索远端验收" },
|
||||
],
|
||||
elapsed_ms: 1260,
|
||||
result_items: [
|
||||
{
|
||||
kind: "wiki",
|
||||
knowledge_base_id: "kb-digital-china",
|
||||
knowledge_base: "数字中国政策目录智能检索远端验收",
|
||||
slug: "digital-china/timeline",
|
||||
page_ref: "wiki-page:v1:digital-china-timeline",
|
||||
title: "数字中国建设时间线",
|
||||
content_markdown:
|
||||
"页面正文概览,包含建设目标、关键年份、量化指标和制度保障。",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
isLoading
|
||||
onOpenResult={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /读取结果/);
|
||||
assert.match(html, /数字中国建设时间线/);
|
||||
assert.match(html, /页面正文概览,包含建设目标/);
|
||||
assert.match(html, /右侧查看/);
|
||||
assert.match(html, /耗时 1\.3 秒/);
|
||||
assert.match(html, /覆盖 1 个知识库:数字中国政策目录智能检索远端验收/);
|
||||
assert.doesNotMatch(html, /知识库知识库/);
|
||||
assert.match(html, /data-retrieval-result-detail="true"/);
|
||||
});
|
||||
|
||||
test("shows all usable document-read results without a generic partial warning", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "document_chunk_read",
|
||||
status: "completed",
|
||||
result_count: 20,
|
||||
partial: true,
|
||||
warning_codes: ["PROVIDER_CONTEXT_PARTIAL"],
|
||||
message: "原文上下文读取完成,找到 20 条结果",
|
||||
result_items: Array.from({ length: 20 }, (_, index) => ({
|
||||
kind: "raw",
|
||||
knowledge_base_id: "kb-news",
|
||||
knowledge_base: "国内新闻",
|
||||
knowledge_id: "document-long-news",
|
||||
chunk_id: `chunk-${index + 1}`,
|
||||
title: `长篇新闻第 ${index + 1} 段`,
|
||||
content: `原文上下文第 ${index + 1} 段`,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
isLoading
|
||||
onOpenResult={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(html.match(/data-retrieval-result-detail="true"/g) ?? []).length,
|
||||
20,
|
||||
);
|
||||
assert.match(html, /长篇新闻第 1 段/);
|
||||
assert.match(html, /长篇新闻第 20 段/);
|
||||
assert.doesNotMatch(html, /部分数据源返回警告/);
|
||||
assert.doesNotMatch(html, /部分完成/);
|
||||
});
|
||||
|
||||
test("keeps historical results without stable ids as non-clickable summaries", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[
|
||||
{
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "knowledge_search",
|
||||
status: "completed",
|
||||
result_count: 1,
|
||||
result_items: [
|
||||
{
|
||||
kind: "raw",
|
||||
knowledge_base: "旧知识库",
|
||||
title: "旧检索结果",
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(html, /data-retrieval-result-summary="true"/);
|
||||
assert.match(html, /该历史检索结果缺少稳定详情标识/);
|
||||
assert.doesNotMatch(html, /data-retrieval-result-detail="true"/);
|
||||
});
|
||||
|
||||
test("uses the whole turn as the dedupe scope for interleaved retrieval cards", () => {
|
||||
const firstFrame: KnowledgeRetrievalProgressFrame = {
|
||||
type: "knowledge_retrieval_progress",
|
||||
version: 1,
|
||||
mode: "smart_reasoning",
|
||||
operation: "knowledge_search",
|
||||
retrieval_id: "raw-first",
|
||||
status: "completed",
|
||||
result_count: 1,
|
||||
result_items: [
|
||||
{
|
||||
kind: "raw",
|
||||
knowledge_base_id: "kb-news",
|
||||
knowledge_id: "document-trump",
|
||||
chunk_id: "chunk-1",
|
||||
title: "特朗普生平.txt",
|
||||
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
|
||||
},
|
||||
],
|
||||
};
|
||||
const repeatedFrame: KnowledgeRetrievalProgressFrame = {
|
||||
...firstFrame,
|
||||
retrieval_id: "raw-second",
|
||||
result_items: [
|
||||
{
|
||||
kind: "raw",
|
||||
knowledge_base_id: "kb-news",
|
||||
knowledge_id: "document-trump",
|
||||
chunk_id: "chunk-provider-remapped",
|
||||
title: "特朗普生平.txt",
|
||||
content: "唐纳德·特朗普于1946年出生,曾任美国总统并从事商业活动。",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const firstHtml = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[firstFrame]}
|
||||
dedupeScopeMessages={[]}
|
||||
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
|
||||
isLoading
|
||||
onOpenResult={() => undefined}
|
||||
/>,
|
||||
);
|
||||
const repeatedHtml = renderToStaticMarkup(
|
||||
<SmartKnowledgeRetrievalTrace
|
||||
messages={[]}
|
||||
progressFrames={[repeatedFrame]}
|
||||
dedupeScopeMessages={[]}
|
||||
dedupeScopeProgressFrames={[firstFrame, repeatedFrame]}
|
||||
isLoading
|
||||
onOpenResult={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
(firstHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(repeatedHtml.match(/data-retrieval-result-detail="true"/g) ?? []).length,
|
||||
0,
|
||||
);
|
||||
assert.match(repeatedHtml, /本次查询结果均已在前面展示/);
|
||||
assert.doesNotMatch(repeatedHtml, /命中 1 条/);
|
||||
});
|
||||
@@ -0,0 +1,525 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
BookOpenTextIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
CirclePauseIcon,
|
||||
DatabaseIcon,
|
||||
Loader2Icon,
|
||||
PanelRightOpenIcon,
|
||||
SearchCheckIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
|
||||
import {
|
||||
buildSmartKnowledgeRetrievalRounds,
|
||||
type KnowledgeRetrievalProgressFrame,
|
||||
type SmartKnowledgeRetrievalAction,
|
||||
type SmartKnowledgeRetrievalResultItem,
|
||||
} from "@/core/threads/smart-knowledge-retrieval";
|
||||
import type { WeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function actionStatusText(action: SmartKnowledgeRetrievalAction): string {
|
||||
if (action.status === "failed") return "检索失败";
|
||||
if (action.status === "interrupted") return "已中止";
|
||||
if (action.status === "running") {
|
||||
return action.surface === "wiki" ? "正在查 Wiki" : "正在查知识库";
|
||||
}
|
||||
if (action.allResultsAlreadyShown) return "均为已展示结果";
|
||||
const hasUsableResults =
|
||||
action.resultItems.length > 0 || (action.resultCount ?? 0) > 0;
|
||||
const partialPrefix = action.partial && !hasUsableResults ? "部分完成 · " : "";
|
||||
if (action.kind === "search") {
|
||||
return `${partialPrefix}命中 ${action.resultCount ?? 0} 条`;
|
||||
}
|
||||
return typeof action.resultCount === "number"
|
||||
? `${partialPrefix}读取完成 · ${action.resultCount} 条`
|
||||
: `${partialPrefix}读取完成`;
|
||||
}
|
||||
|
||||
function formatElapsedTime(elapsedMs: number): string {
|
||||
const normalized = Math.max(0, elapsedMs);
|
||||
if (normalized < 1_000) return `${Math.round(normalized)} 毫秒`;
|
||||
if (normalized < 60_000) {
|
||||
const seconds = (normalized / 1_000).toFixed(1).replace(/\.0$/, "");
|
||||
return `${seconds} 秒`;
|
||||
}
|
||||
const roundedSeconds = Math.round(normalized / 1_000);
|
||||
const minutes = Math.floor(roundedSeconds / 60);
|
||||
const seconds = roundedSeconds % 60;
|
||||
return seconds > 0 ? `${minutes} 分 ${seconds} 秒` : `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
function channelText(channel: string | undefined): string | null {
|
||||
if (!channel) return null;
|
||||
if (channel === "local_wiki_mirror") return "本地 Wiki 镜像";
|
||||
if (channel === "provider") return "知识库 Wiki";
|
||||
if (channel === "mixed") return "本地镜像 + 知识库 Wiki";
|
||||
return channel;
|
||||
}
|
||||
|
||||
function ActionStatusIcon({ action }: { action: SmartKnowledgeRetrievalAction }) {
|
||||
if (action.status === "running") {
|
||||
return <Loader2Icon className="size-4 animate-spin text-primary" />;
|
||||
}
|
||||
if (action.status === "completed") {
|
||||
return <CheckCircle2Icon className="size-4 text-emerald-600 dark:text-emerald-400" />;
|
||||
}
|
||||
if (action.status === "failed") {
|
||||
return <AlertCircleIcon className="size-4 text-amber-600 dark:text-amber-400" />;
|
||||
}
|
||||
return <CirclePauseIcon className="size-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function resultDetailTarget(
|
||||
item: SmartKnowledgeRetrievalResultItem,
|
||||
): WeKnoraSourceTarget | null {
|
||||
if (!item.knowledgeBaseId) return null;
|
||||
if (item.kind === "wiki" && item.wikiSlug) {
|
||||
return {
|
||||
knowledgeBaseId: item.knowledgeBaseId,
|
||||
wikiSlug: item.wikiSlug,
|
||||
tab: "wiki",
|
||||
};
|
||||
}
|
||||
if (item.kind === "raw" && (item.chunkId || item.documentId)) {
|
||||
return {
|
||||
knowledgeBaseId: item.knowledgeBaseId,
|
||||
chunkId: item.chunkId,
|
||||
documentId: item.documentId,
|
||||
tab: "documents",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function RetrievalAction({
|
||||
action,
|
||||
onOpenResult,
|
||||
}: {
|
||||
action: SmartKnowledgeRetrievalAction;
|
||||
onOpenResult?: (
|
||||
item: SmartKnowledgeRetrievalResultItem,
|
||||
target: WeKnoraSourceTarget,
|
||||
) => void;
|
||||
}) {
|
||||
const SurfaceIcon = action.surface === "wiki" ? BookOpenTextIcon : DatabaseIcon;
|
||||
const channel = channelText(action.channel);
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-border/70 bg-background/75 px-3 py-2.5"
|
||||
data-retrieval-operation={action.operation}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<SurfaceIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
|
||||
<span className="text-sm font-medium text-foreground">{action.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-xs",
|
||||
action.status === "running"
|
||||
? "text-primary"
|
||||
: action.status === "completed"
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: action.status === "failed"
|
||||
? "text-amber-700 dark:text-amber-300"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<ActionStatusIcon action={action} />
|
||||
{actionStatusText(action)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{action.queries.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">查询</span>
|
||||
{action.queries.map((query) => (
|
||||
<span
|
||||
key={query}
|
||||
className="max-w-full rounded-md bg-muted px-2 py-0.5 text-foreground/85 break-all"
|
||||
>
|
||||
{query}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{typeof action.knowledgeBaseCount === "number" ? (
|
||||
<span>
|
||||
覆盖 {action.knowledgeBaseCount} 个知识库
|
||||
{action.knowledgeBases.length > 0
|
||||
? `:${action.knowledgeBases.join("、")}`
|
||||
: ""}
|
||||
</span>
|
||||
) : action.knowledgeBases.length > 0 ? (
|
||||
<span>知识库:{action.knowledgeBases.join("、")}</span>
|
||||
) : null}
|
||||
{typeof action.scannedPages === "number" && (
|
||||
<span>扫描 {action.scannedPages} 个 Wiki 页面</span>
|
||||
)}
|
||||
{typeof action.scannedChunks === "number" && (
|
||||
<span>扫描 {action.scannedChunks} 个原文片段</span>
|
||||
)}
|
||||
{action.kind === "read" && typeof action.total === "number" && (
|
||||
<span>共 {action.total} 个片段</span>
|
||||
)}
|
||||
{channel && <span>通道:{channel}</span>}
|
||||
{typeof action.elapsedMs === "number" && (
|
||||
<span>耗时 {formatElapsedTime(action.elapsedMs)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{action.allResultsAlreadyShown ? (
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
本次查询结果均已在前面展示
|
||||
</p>
|
||||
) : action.resultItems.length > 0 ? (
|
||||
<div className="mt-2">
|
||||
<div className="mb-1.5 text-xs text-muted-foreground">
|
||||
{action.kind === "read" ? "读取结果" : "查询结果"}
|
||||
</div>
|
||||
{action.kind === "read" ? (
|
||||
<div className="space-y-2">
|
||||
{action.resultItems.map((item) => {
|
||||
const target = onOpenResult
|
||||
? resultDetailTarget(item)
|
||||
: null;
|
||||
const preview = item.preview?.trim().slice(0, 600);
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{item.title}
|
||||
</span>
|
||||
{target && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
|
||||
右侧查看
|
||||
<PanelRightOpenIcon className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{preview && (
|
||||
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs leading-5 text-muted-foreground">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return target ? (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-retrieval-result-detail
|
||||
className="block w-full rounded-lg border border-primary/20 bg-primary/[0.045] px-3 py-2.5 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.075] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
title={`在右侧查看:${item.title}`}
|
||||
onClick={() => onOpenResult?.(item, target)}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
key={item.id}
|
||||
aria-disabled="true"
|
||||
data-retrieval-result-summary
|
||||
className="rounded-lg border border-border/70 bg-muted/45 px-3 py-2.5"
|
||||
title="该历史检索结果缺少稳定详情标识"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{action.resultItems.map((item) => {
|
||||
const target = onOpenResult
|
||||
? resultDetailTarget(item)
|
||||
: null;
|
||||
return target ? (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-retrieval-result-detail
|
||||
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-primary/20 bg-primary/[0.055] px-2 py-1 text-left text-xs leading-5 text-foreground transition-colors hover:border-primary/40 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
title={`在右侧查看:${item.title}`}
|
||||
onClick={() => onOpenResult?.(item, target)}
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
<PanelRightOpenIcon className="size-3.5 shrink-0 text-primary" />
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
key={item.id}
|
||||
aria-disabled="true"
|
||||
data-retrieval-result-summary
|
||||
className="inline-flex max-w-full rounded-md bg-muted px-2 py-1 text-xs leading-5 text-muted-foreground"
|
||||
title="该历史检索结果缺少稳定详情标识"
|
||||
>
|
||||
<span className="truncate">{item.title}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{(action.resultCount ?? 0) > action.resultItems.length && (
|
||||
<span className="inline-flex items-center px-1 text-xs text-muted-foreground">
|
||||
另有 {(action.resultCount ?? 0) - action.resultItems.length} 条结果
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : action.resultTitles.length > 0 ? (
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
结果摘要:{action.resultTitles.join(";")}
|
||||
{(action.resultCount ?? 0) > action.resultTitles.length ? ";…" : ""}
|
||||
</p>
|
||||
) : null}
|
||||
{action.message && (
|
||||
<p className="mt-1.5 text-xs leading-5 text-muted-foreground">
|
||||
{action.message}
|
||||
</p>
|
||||
)}
|
||||
{action.error && (
|
||||
<p className="mt-1.5 text-xs leading-5 text-amber-700 dark:text-amber-300">
|
||||
{action.error}
|
||||
</p>
|
||||
)}
|
||||
{/* Provider warning codes stay in the structured trace for
|
||||
diagnostics. The old generic warning was misleading both when
|
||||
useful evidence existed and when the result set was empty. */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SmartKnowledgeRetrievalTrace({
|
||||
messages,
|
||||
progressFrames = [],
|
||||
dedupeScopeMessages,
|
||||
dedupeScopeProgressFrames,
|
||||
isLoading,
|
||||
onOpenResult,
|
||||
}: {
|
||||
messages: Message[];
|
||||
progressFrames?: KnowledgeRetrievalProgressFrame[];
|
||||
/**
|
||||
* The whole current user-turn group. Interleaved rendering splits one
|
||||
* retrieval invocation into one component, but result dedupe must still see
|
||||
* every invocation in the turn.
|
||||
*/
|
||||
dedupeScopeMessages?: Message[];
|
||||
dedupeScopeProgressFrames?: KnowledgeRetrievalProgressFrame[];
|
||||
isLoading: boolean;
|
||||
/** @deprecated 仅为兼容历史分段调用,展示层不再暴露检索轮次。 */
|
||||
roundOffset?: number;
|
||||
onOpenResult?: (
|
||||
item: SmartKnowledgeRetrievalResultItem,
|
||||
target: WeKnoraSourceTarget,
|
||||
) => void;
|
||||
}) {
|
||||
const rounds = useMemo(
|
||||
() => {
|
||||
const localRounds = buildSmartKnowledgeRetrievalRounds(
|
||||
messages,
|
||||
progressFrames,
|
||||
isLoading,
|
||||
);
|
||||
if (!dedupeScopeMessages) return localRounds;
|
||||
|
||||
const globallyDedupedActions = new Map(
|
||||
buildSmartKnowledgeRetrievalRounds(
|
||||
dedupeScopeMessages,
|
||||
dedupeScopeProgressFrames ?? [],
|
||||
isLoading,
|
||||
)
|
||||
.flatMap((round) => round.actions)
|
||||
.map((action) => [action.id, action] as const),
|
||||
);
|
||||
return localRounds.map((round) => ({
|
||||
...round,
|
||||
actions: round.actions.map((action) => {
|
||||
const globalAction = globallyDedupedActions.get(action.id);
|
||||
return globalAction ? { ...globalAction, round: action.round } : action;
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[
|
||||
dedupeScopeMessages,
|
||||
dedupeScopeProgressFrames,
|
||||
isLoading,
|
||||
messages,
|
||||
progressFrames,
|
||||
],
|
||||
);
|
||||
const actions = rounds.flatMap((round) => round.actions);
|
||||
// The chat can keep streaming its final answer after retrieval has already
|
||||
// finished. Only an unfinished retrieval action owns the "检索中" state;
|
||||
// the broader thread loading flag must not keep the spinner alive or block
|
||||
// the collapse control during answer generation.
|
||||
const isActive = actions.some((action) => action.status === "running");
|
||||
const hasFailure = actions.some((action) => action.status === "failed");
|
||||
const hasInterrupted = actions.some(
|
||||
(action) => action.status === "interrupted",
|
||||
);
|
||||
const rawHits = actions
|
||||
.filter((action) => action.surface === "raw" && action.kind === "search")
|
||||
.reduce(
|
||||
(total, action) =>
|
||||
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
|
||||
0,
|
||||
);
|
||||
const wikiHits = actions
|
||||
.filter((action) => action.surface === "wiki" && action.kind === "search")
|
||||
.reduce(
|
||||
(total, action) =>
|
||||
total + (action.allResultsAlreadyShown ? 0 : (action.resultCount ?? 0)),
|
||||
0,
|
||||
);
|
||||
const elapsedActions = actions.filter(
|
||||
(action) => typeof action.elapsedMs === "number",
|
||||
);
|
||||
const cumulativeElapsedMs =
|
||||
elapsedActions.length > 0
|
||||
? elapsedActions.reduce(
|
||||
(total, action) => total + (action.elapsedMs ?? 0),
|
||||
0,
|
||||
)
|
||||
: undefined;
|
||||
// A live trace opens so newly completed result cards remain visible while
|
||||
// the final answer starts streaming. Replayed history starts collapsed.
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
isLoading && actions.length > 0,
|
||||
);
|
||||
const wasActive = useRef(isActive);
|
||||
const wasLoading = useRef(isLoading);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wasActive.current && isActive) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
if (wasLoading.current && !isLoading) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
wasActive.current = isActive;
|
||||
wasLoading.current = isLoading;
|
||||
}, [isActive, isLoading]);
|
||||
|
||||
// An in-flight retrieval always stays visible. A just-completed live trace
|
||||
// remains open and user-collapsible while the answer streams; replayed
|
||||
// history starts collapsed and can be reopened for inspection.
|
||||
const isOpen = isActive || isExpanded;
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!isActive) setIsExpanded(open);
|
||||
}}
|
||||
>
|
||||
<section
|
||||
aria-label="智能检索过程"
|
||||
aria-live={isActive ? "polite" : undefined}
|
||||
className="w-full rounded-xl border border-primary/20 bg-primary/[0.035] p-3.5 shadow-sm"
|
||||
data-smart-knowledge-retrieval-trace
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{isActive ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SearchCheckIcon className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">智能检索过程</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: hasFailure
|
||||
? "bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
||||
: hasInterrupted
|
||||
? "bg-muted text-muted-foreground"
|
||||
: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
)}
|
||||
>
|
||||
{isActive
|
||||
? "检索中"
|
||||
: hasFailure
|
||||
? "已完成(部分步骤失败)"
|
||||
: hasInterrupted
|
||||
? "检索已结束(部分步骤未完成)"
|
||||
: "检索完成"}
|
||||
</span>
|
||||
{!isActive && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-primary/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
aria-label={isOpen ? "收起智能检索过程" : "展开智能检索过程"}
|
||||
>
|
||||
<span>{isOpen ? "收起" : "展开"}</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isActive && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
调用知识库 {actions.length} 次 · 原始文档命中 {rawHits} 条 · Wiki 命中 {wikiHits} 条
|
||||
{typeof cumulativeElapsedMs === "number"
|
||||
? ` · 累计耗时 ${formatElapsedTime(cumulativeElapsedMs)}`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
{isActive && (
|
||||
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
|
||||
{!isActive && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
展示实际查询、检索范围与命中摘要,不包含模型内部思考。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
{actions.map((action) => (
|
||||
<RetrievalAction
|
||||
key={action.id}
|
||||
action={action}
|
||||
onOpenResult={onOpenResult}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</section>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// Node's type-stripping test runner requires the explicit TypeScript suffix.
|
||||
// @ts-expect-error The application compiler intentionally uses extensionless imports.
|
||||
import {
|
||||
GENERATION_IDLE_MS,
|
||||
ROLLING_WINDOW_MS,
|
||||
charsToTokensPerSec,
|
||||
messageHasToolCalls,
|
||||
rollingTokensPerSec,
|
||||
shouldFreezeGeneration,
|
||||
usageTokensPerSec,
|
||||
} from "./speed-metrics.ts";
|
||||
|
||||
test("rolling window reports the recent rate while characters grow", () => {
|
||||
const samples = [
|
||||
{ at: 0, chars: 0 },
|
||||
{ at: 500, chars: 100 },
|
||||
{ at: 1000, chars: 200 },
|
||||
];
|
||||
const rate = rollingTokensPerSec(samples, 200, 1000, 1000);
|
||||
// 200 chars / 1000ms / 2 chars-per-token = 100 tok/s
|
||||
assert.equal(rate, 100);
|
||||
});
|
||||
|
||||
test("rolling window drops to 0 when every sample is older than the window", () => {
|
||||
const samples = [
|
||||
{ at: 0, chars: 0 },
|
||||
{ at: 200, chars: 80 },
|
||||
{ at: 400, chars: 160 },
|
||||
];
|
||||
const now = 400 + ROLLING_WINDOW_MS + 50;
|
||||
assert.equal(rollingTokensPerSec(samples, 160, now), 0);
|
||||
});
|
||||
|
||||
test("rolling window decays toward 0 after a mid-stream pause", () => {
|
||||
const samples = [
|
||||
{ at: 0, chars: 0 },
|
||||
{ at: 400, chars: 80 },
|
||||
];
|
||||
const live = rollingTokensPerSec(samples, 80, 500, 1000);
|
||||
const paused = rollingTokensPerSec(samples, 80, 900, 1000);
|
||||
assert.ok(live > paused);
|
||||
assert.ok(paused > 0);
|
||||
assert.equal(rollingTokensPerSec(samples, 80, 400 + 1000 + 1, 1000), 0);
|
||||
});
|
||||
|
||||
test("usageTokensPerSec is output tokens over the segment duration", () => {
|
||||
assert.equal(usageTokensPerSec(278, 6200), (278 / 6200) * 1000);
|
||||
assert.equal(usageTokensPerSec(0, 1000), 0);
|
||||
assert.equal(usageTokensPerSec(10, 0), 0);
|
||||
});
|
||||
|
||||
test("charsToTokensPerSec uses the 2-chars-per-token heuristic", () => {
|
||||
assert.equal(charsToTokensPerSec(100, 1000), 50);
|
||||
});
|
||||
|
||||
test("shouldFreezeGeneration ends the segment on the next call, tools, idle usage, or run stop", () => {
|
||||
const base = {
|
||||
endedAt: null,
|
||||
isLoading: true,
|
||||
isLatestGenerating: true,
|
||||
hasToolCalls: false,
|
||||
grewThisTick: false,
|
||||
lastGrewAt: 1000,
|
||||
now: 1100,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
assert.equal(shouldFreezeGeneration(base), false);
|
||||
assert.equal(
|
||||
shouldFreezeGeneration({ ...base, isLatestGenerating: false }),
|
||||
true,
|
||||
);
|
||||
assert.equal(shouldFreezeGeneration({ ...base, hasToolCalls: true }), true);
|
||||
assert.equal(
|
||||
shouldFreezeGeneration({
|
||||
...base,
|
||||
hasToolCalls: true,
|
||||
grewThisTick: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFreezeGeneration({
|
||||
...base,
|
||||
outputTokens: 40,
|
||||
now: 1000 + GENERATION_IDLE_MS,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldFreezeGeneration({
|
||||
...base,
|
||||
outputTokens: 40,
|
||||
now: 1000 + GENERATION_IDLE_MS - 1,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldFreezeGeneration({ ...base, isLoading: false }), true);
|
||||
assert.equal(shouldFreezeGeneration({ ...base, endedAt: 1000 }), false);
|
||||
});
|
||||
|
||||
test("messageHasToolCalls reads both top-level and additional_kwargs", () => {
|
||||
assert.equal(messageHasToolCalls({}), false);
|
||||
assert.equal(
|
||||
messageHasToolCalls({
|
||||
tool_calls: [{ id: "1", name: "search" }],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
messageHasToolCalls({
|
||||
additional_kwargs: { tool_calls: [{ id: "1" }] },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Shared tok/s helpers for the chat / roundtable speed pills.
|
||||
*
|
||||
* Live rate uses a rolling window with a virtual tail at "now" so a pause
|
||||
* (no new characters) decays toward 0 instead of freezing on the last two
|
||||
* samples. Final rate prefers provider `output_tokens` over the segment
|
||||
* duration, and only after that model-call segment has ended.
|
||||
*/
|
||||
|
||||
export const CHARS_PER_TOKEN = 2;
|
||||
export const SAMPLE_THROTTLE_MS = 60;
|
||||
export const ROLLING_WINDOW_MS = 1000;
|
||||
export const MAX_SPEED_SAMPLES = 256;
|
||||
/** Idle after last visible growth before we treat this model call as finished. */
|
||||
export const GENERATION_IDLE_MS = 400;
|
||||
|
||||
export type SpeedSample = { at: number; chars: number };
|
||||
|
||||
export function estimateTokensFromChars(chars: number): number {
|
||||
return Math.max(0, chars) / CHARS_PER_TOKEN;
|
||||
}
|
||||
|
||||
export function charsToTokensPerSec(chars: number, elapsedMs: number): number {
|
||||
if (elapsedMs <= 0) return 0;
|
||||
return (estimateTokensFromChars(chars) / elapsedMs) * 1000;
|
||||
}
|
||||
|
||||
export function usageTokensPerSec(
|
||||
outputTokens: number,
|
||||
durationMs: number,
|
||||
): number {
|
||||
if (outputTokens <= 0 || durationMs <= 0) return 0;
|
||||
return (outputTokens / durationMs) * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantaneous tok/s over the last `windowMs`.
|
||||
*
|
||||
* The tail is always `(now, lastChars)`. If every sample is older than the
|
||||
* window, the result is 0 — a generation pause must not keep the last
|
||||
* observed rate pinned on screen.
|
||||
*/
|
||||
export function rollingTokensPerSec(
|
||||
samples: SpeedSample[],
|
||||
lastChars: number,
|
||||
now: number,
|
||||
windowMs: number = ROLLING_WINDOW_MS,
|
||||
): number {
|
||||
if (samples.length === 0 || windowMs <= 0) return 0;
|
||||
|
||||
const windowStart = now - windowMs;
|
||||
let headIdx = samples.findIndex((sample) => sample.at >= windowStart);
|
||||
if (headIdx === -1) {
|
||||
return 0;
|
||||
}
|
||||
if (headIdx > 0) {
|
||||
headIdx -= 1;
|
||||
}
|
||||
|
||||
const head = samples[headIdx];
|
||||
if (!head) return 0;
|
||||
const elapsedMs = now - head.at;
|
||||
if (elapsedMs <= 0) return 0;
|
||||
return charsToTokensPerSec(Math.max(0, lastChars - head.chars), elapsedMs);
|
||||
}
|
||||
|
||||
export function pushSpeedSample(
|
||||
samples: SpeedSample[],
|
||||
at: number,
|
||||
chars: number,
|
||||
): void {
|
||||
samples.push({ at, chars });
|
||||
if (samples.length > MAX_SPEED_SAMPLES) {
|
||||
samples.splice(0, samples.length - MAX_SPEED_SAMPLES);
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldFreezeGeneration(args: {
|
||||
endedAt: number | null;
|
||||
isLoading: boolean;
|
||||
isLatestGenerating: boolean;
|
||||
hasToolCalls: boolean;
|
||||
grewThisTick: boolean;
|
||||
lastGrewAt: number;
|
||||
now: number;
|
||||
outputTokens: number;
|
||||
}): boolean {
|
||||
if (args.endedAt != null) return false;
|
||||
if (!args.isLatestGenerating) return true;
|
||||
if (args.hasToolCalls && !args.grewThisTick) return true;
|
||||
if (
|
||||
args.outputTokens > 0 &&
|
||||
args.now - args.lastGrewAt >= GENERATION_IDLE_MS
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !args.isLoading;
|
||||
}
|
||||
|
||||
export function messageHasToolCalls(message: {
|
||||
tool_calls?: unknown[] | null;
|
||||
additional_kwargs?: { tool_calls?: unknown[] } | null;
|
||||
}): boolean {
|
||||
const direct = message.tool_calls;
|
||||
if (Array.isArray(direct) && direct.length > 0) return true;
|
||||
const extra = message.additional_kwargs?.tool_calls;
|
||||
return Array.isArray(extra) && extra.length > 0;
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import {
|
||||
ChainOfThought,
|
||||
ChainOfThoughtContent,
|
||||
@@ -19,7 +17,10 @@ import { ShineBorder } from "@/components/ui/shine-border";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { hasToolCalls } from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
|
||||
import {
|
||||
SafeStreamdown,
|
||||
streamdownPluginsWithWordAnimation,
|
||||
} from "@/core/streamdown";
|
||||
import { useSubtask } from "@/core/tasks/context";
|
||||
import { explainLastToolCall } from "@/core/tools/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -126,12 +127,12 @@ export function SubtaskCard({
|
||||
{task.prompt && (
|
||||
<ChainOfThoughtStep
|
||||
label={
|
||||
<Streamdown
|
||||
<SafeStreamdown
|
||||
{...streamdownPluginsWithWordAnimation}
|
||||
components={{ a: CitationLink }}
|
||||
>
|
||||
{task.prompt}
|
||||
</Streamdown>
|
||||
</SafeStreamdown>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { DownloadIcon, Loader2 } from "lucide-react";
|
||||
import { DownloadIcon, InfoIcon, Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { DEFAULT_UPLOAD_POLICY, useUploadPolicy } from "@/core/uploads";
|
||||
|
||||
const markdownClassName =
|
||||
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
||||
@@ -56,20 +58,111 @@ function kindOf(filename: string): PreviewKind {
|
||||
return "binary";
|
||||
}
|
||||
|
||||
function withReplacedExtension(url: string): string {
|
||||
// Replace the path extension with .md, keeping any query string intact.
|
||||
function withAppendedMarkdownExtension(url: string): string {
|
||||
const [base, query] = url.split("?");
|
||||
const appended = `${base}.md`;
|
||||
return query ? `${appended}?${query}` : appended;
|
||||
}
|
||||
|
||||
function withLegacyReplacedExtension(url: string): string {
|
||||
// Compatibility with uploads converted before sidecars switched from
|
||||
// `report.md` to the collision-safe `report.docx.md` naming scheme.
|
||||
const [base, query] = url.split("?");
|
||||
const i = base.lastIndexOf(".");
|
||||
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
|
||||
return query ? `${swapped}?${query}` : swapped;
|
||||
}
|
||||
|
||||
type ArtifactPreviewResponse = {
|
||||
path: string;
|
||||
name: string;
|
||||
total_bytes: number;
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
strategy: string;
|
||||
stats?: Record<string, unknown> | null;
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
type PreviewMeta = Pick<
|
||||
ArtifactPreviewResponse,
|
||||
"total_bytes" | "truncated" | "strategy" | "warnings"
|
||||
>;
|
||||
|
||||
function parseTotalBytes(response: Response): number | undefined {
|
||||
const contentRange = response.headers.get("Content-Range");
|
||||
const rangeTotal = contentRange?.match(/\/(\d+)$/)?.[1];
|
||||
if (rangeTotal) return Number(rangeTotal);
|
||||
const contentLengthHeader = response.headers.get("Content-Length");
|
||||
if (!contentLengthHeader) return undefined;
|
||||
const contentLength = Number(contentLengthHeader);
|
||||
return Number.isFinite(contentLength) ? contentLength : undefined;
|
||||
}
|
||||
|
||||
/** Read a streamed response up to maxBytes and cancel the rest. */
|
||||
async function readBoundedTextResponse(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): Promise<{ content: string; totalBytes: number; truncated: boolean }> {
|
||||
if (!response.body) throw new Error("浏览器不支持流式预览");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const declaredTotal = parseTotalBytes(response);
|
||||
let received = 0;
|
||||
let content = "";
|
||||
let completed = false;
|
||||
|
||||
while (received < maxBytes) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
if (!value) continue;
|
||||
const remaining = maxBytes - received;
|
||||
const slice = value.byteLength > remaining ? value.subarray(0, remaining) : value;
|
||||
received += slice.byteLength;
|
||||
content += decoder.decode(slice, { stream: received < maxBytes });
|
||||
if (value.byteLength > remaining || received >= maxBytes) {
|
||||
await reader.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
content += decoder.decode();
|
||||
const totalBytes = declaredTotal ?? received;
|
||||
return {
|
||||
content,
|
||||
totalBytes,
|
||||
truncated: !completed || totalBytes > received,
|
||||
};
|
||||
}
|
||||
|
||||
function strategyLabel(strategy: string): string {
|
||||
if (strategy.includes("sampling") || strategy.includes("sample")) {
|
||||
return "流式统计 + 开头/中段/结尾分块采样";
|
||||
}
|
||||
if (strategy === "complete-text") return "完整文本";
|
||||
if (strategy === "bounded-prefix") return "有界前缀";
|
||||
if (strategy === "client-bounded-prefix") return "兼容模式有界前缀";
|
||||
return strategy;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(2)} KiB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an auth-gated artifact (the artifact route requires an
|
||||
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
|
||||
* hence the 401s) and expose it as an object URL for inline display.
|
||||
*/
|
||||
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
||||
export function useAuthedObjectUrl(
|
||||
url: string | null,
|
||||
enabled: boolean,
|
||||
requestFile: typeof apiFetch = apiFetch,
|
||||
) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -81,9 +174,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
||||
}
|
||||
let cancelled = false;
|
||||
let created: string | null = null;
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
apiFetch(url)
|
||||
requestFile(url, { signal: controller.signal })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.blob();
|
||||
@@ -102,9 +196,10 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [url, enabled]);
|
||||
}, [url, enabled, requestFile]);
|
||||
|
||||
return { objectUrl, loading, error };
|
||||
}
|
||||
@@ -122,47 +217,152 @@ export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
||||
export function UploadedFilePreviewDialog({
|
||||
filename,
|
||||
fileUrl,
|
||||
threadId,
|
||||
virtualPath,
|
||||
requestFile = apiFetch,
|
||||
onDownload,
|
||||
downloading = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
filename: string;
|
||||
/** Absolute artifact URL for the original uploaded file. */
|
||||
fileUrl: string;
|
||||
/** Used by the bounded preview endpoint; optional for old/public messages. */
|
||||
threadId?: string;
|
||||
virtualPath?: string;
|
||||
/** External skill links supply an unauthenticated fetch, avoiding token leakage. */
|
||||
requestFile?: typeof apiFetch;
|
||||
/** Authenticated original-file download used by retrieved-file cards. */
|
||||
onDownload?: () => void | Promise<void>;
|
||||
downloading?: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: uploadPolicy } = useUploadPolicy();
|
||||
const effectivePolicy = uploadPolicy ?? DEFAULT_UPLOAD_POLICY;
|
||||
const maxPreviewChars = Math.max(
|
||||
256,
|
||||
Math.min(
|
||||
32_000,
|
||||
effectivePolicy.large_file.max_result_chars,
|
||||
effectivePolicy.large_file.hard_max_result_chars,
|
||||
),
|
||||
);
|
||||
const kind = useMemo(() => kindOf(filename), [filename]);
|
||||
const ext = useMemo(() => extOf(filename), [filename]);
|
||||
|
||||
// image / pdf → fetch as authed blob and display via object URL.
|
||||
const blobEnabled = open && (kind === "image" || kind === "pdf");
|
||||
const { objectUrl, loading: blobLoading, error: blobError } =
|
||||
useAuthedObjectUrl(fileUrl, blobEnabled);
|
||||
useAuthedObjectUrl(fileUrl, blobEnabled, requestFile);
|
||||
|
||||
// text / office → fetch text (office reads the converted sibling .md).
|
||||
const textUrl = useMemo(
|
||||
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
|
||||
const textUrls = useMemo(
|
||||
() =>
|
||||
kind === "office"
|
||||
? Array.from(
|
||||
new Set([
|
||||
withAppendedMarkdownExtension(fileUrl),
|
||||
withLegacyReplacedExtension(fileUrl),
|
||||
]),
|
||||
)
|
||||
: [fileUrl],
|
||||
[kind, fileUrl],
|
||||
);
|
||||
const previewPaths = useMemo(
|
||||
() =>
|
||||
virtualPath
|
||||
? kind === "office"
|
||||
? Array.from(
|
||||
new Set([
|
||||
withAppendedMarkdownExtension(virtualPath),
|
||||
withLegacyReplacedExtension(virtualPath),
|
||||
]),
|
||||
)
|
||||
: [virtualPath]
|
||||
: [],
|
||||
[kind, virtualPath],
|
||||
);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [previewMeta, setPreviewMeta] = useState<PreviewMeta | null>(null);
|
||||
const [textLoading, setTextLoading] = useState(false);
|
||||
const [textError, setTextError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || (kind !== "text" && kind !== "office")) return;
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setTextLoading(true);
|
||||
setTextError(null);
|
||||
setText(null);
|
||||
apiFetch(textUrl)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
})
|
||||
.then((body) => {
|
||||
if (!cancelled) setText(body);
|
||||
})
|
||||
setPreviewMeta(null);
|
||||
|
||||
const load = async () => {
|
||||
if (threadId && previewPaths.length > 0) {
|
||||
for (const previewPath of previewPaths) {
|
||||
try {
|
||||
const endpoint = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(
|
||||
threadId,
|
||||
)}/artifact-preview?path=${encodeURIComponent(
|
||||
previewPath,
|
||||
)}&max_chars=${maxPreviewChars}`;
|
||||
const response = await apiFetch(endpoint, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const body = (await response.json()) as ArtifactPreviewResponse;
|
||||
const rawContent = String(body.content ?? "");
|
||||
const content = rawContent.slice(0, maxPreviewChars);
|
||||
if (!cancelled) {
|
||||
setText(content);
|
||||
setPreviewMeta({
|
||||
total_bytes: Number(body.total_bytes) || 0,
|
||||
truncated:
|
||||
Boolean(body.truncated) || rawContent.length > content.length,
|
||||
strategy: String(body.strategy || "bounded-prefix"),
|
||||
warnings: Array.isArray(body.warnings) ? body.warnings : [],
|
||||
});
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
// Try a legacy Office sidecar, then fall through to the bounded
|
||||
// Range fallback during a rolling upgrade or public share.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxBytes = Math.max(4_096, maxPreviewChars * 4);
|
||||
let lastError: Error | null = null;
|
||||
for (const textUrl of textUrls) {
|
||||
try {
|
||||
const response = await requestFile(textUrl, {
|
||||
signal: controller.signal,
|
||||
headers: { Range: `bytes=0-${maxBytes - 1}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const bounded = await readBoundedTextResponse(response, maxBytes);
|
||||
if (!cancelled) {
|
||||
const content = bounded.content.slice(0, maxPreviewChars);
|
||||
setText(content);
|
||||
setPreviewMeta({
|
||||
total_bytes: bounded.totalBytes,
|
||||
truncated:
|
||||
bounded.truncated || bounded.content.length > content.length,
|
||||
strategy: "client-bounded-prefix",
|
||||
warnings: [
|
||||
"采样预览接口暂不可用,当前仅显示受限的文件开头,未读取剩余内容。",
|
||||
],
|
||||
});
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error("无法读取文件预览");
|
||||
};
|
||||
|
||||
void load()
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled)
|
||||
setTextError(err instanceof Error ? err.message : String(err));
|
||||
@@ -172,8 +372,9 @@ export function UploadedFilePreviewDialog({
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, kind, textUrl]);
|
||||
}, [open, kind, maxPreviewChars, previewPaths, textUrls, threadId, requestFile]);
|
||||
|
||||
const renderAsMarkdown =
|
||||
kind === "office" || ext === "md" || ext === "markdown";
|
||||
@@ -192,21 +393,36 @@ export function UploadedFilePreviewDialog({
|
||||
style={{
|
||||
width: "min(56rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
height: "min(720px, calc(100dvh - 2rem))",
|
||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
||||
height: "min(720px, calc(100vh - 2rem))",
|
||||
maxHeight: "min(720px, calc(100vh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
|
||||
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
|
||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||
<a
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
||||
>
|
||||
<DownloadIcon className="size-3" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
{onDownload ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={downloading}
|
||||
onClick={() => void onDownload()}
|
||||
>
|
||||
<DownloadIcon className="size-3" />
|
||||
{t.common.download}
|
||||
</Button>
|
||||
) : (
|
||||
<a
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
||||
>
|
||||
<DownloadIcon className="size-3" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -215,7 +431,7 @@ export function UploadedFilePreviewDialog({
|
||||
blobLoading ? (
|
||||
loadingView
|
||||
) : blobError || !objectUrl ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
@@ -229,7 +445,7 @@ export function UploadedFilePreviewDialog({
|
||||
blobLoading ? (
|
||||
loadingView
|
||||
) : blobError || !objectUrl ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
) : (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
@@ -241,22 +457,45 @@ export function UploadedFilePreviewDialog({
|
||||
textLoading ? (
|
||||
loadingView
|
||||
) : textError ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : renderAsMarkdown ? (
|
||||
<MarkdownContent
|
||||
className={markdownClassName}
|
||||
content={text ?? ""}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
) : (
|
||||
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{text ?? ""}
|
||||
</pre>
|
||||
<div className="space-y-3">
|
||||
{previewMeta &&
|
||||
(previewMeta.truncated ||
|
||||
previewMeta.strategy !== "complete-text" ||
|
||||
(previewMeta.warnings?.length ?? 0) > 0) && (
|
||||
<div className="border-amber-300/70 bg-amber-50/70 text-amber-950 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100 rounded-lg border px-3 py-2 text-xs">
|
||||
<div className="flex items-start gap-2 font-medium">
|
||||
<InfoIcon className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{previewMeta.truncated ? "当前为截断/采样预览" : "预览读取方式"}
|
||||
:{strategyLabel(previewMeta.strategy)};文件大小 {formatBytes(previewMeta.total_bytes)},本次最多展示 {maxPreviewChars.toLocaleString()} 个字符。
|
||||
</span>
|
||||
</div>
|
||||
{previewMeta.warnings?.map((warning) => (
|
||||
<p key={warning} className="mt-1 pl-5 opacity-80">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderAsMarkdown ? (
|
||||
<MarkdownContent
|
||||
className={markdownClassName}
|
||||
content={text ?? ""}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{text ?? ""}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} onDownload={onDownload} downloading={downloading} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -264,15 +503,34 @@ export function UploadedFilePreviewDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
|
||||
function BinaryFallback({ fileUrl, label, onDownload, downloading }: {
|
||||
fileUrl: string;
|
||||
label: string;
|
||||
onDownload?: () => void | Promise<void>;
|
||||
downloading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
||||
<p>暂不支持在线预览,请下载原文件查看。</p>
|
||||
{onDownload ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={downloading}
|
||||
onClick={() => void onDownload()}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,29 +7,37 @@ import { extractReasoningContentFromMessage } from "@/core/messages/utils";
|
||||
import { getUsageMetadata } from "@/core/messages/usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
|
||||
/**
|
||||
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
|
||||
* speed indicator for mixed Chinese/English replies without dragging in a
|
||||
* real tokeniser.
|
||||
*/
|
||||
const CHARS_PER_TOKEN = 2;
|
||||
|
||||
/** Sample throttle while a message is streaming. */
|
||||
const SAMPLE_THROTTLE_MS = 60;
|
||||
import {
|
||||
GENERATION_IDLE_MS,
|
||||
SAMPLE_THROTTLE_MS,
|
||||
charsToTokensPerSec,
|
||||
messageHasToolCalls,
|
||||
pushSpeedSample,
|
||||
rollingTokensPerSec,
|
||||
shouldFreezeGeneration,
|
||||
usageTokensPerSec,
|
||||
type SpeedSample,
|
||||
} from "./speed-metrics";
|
||||
|
||||
/**
|
||||
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
|
||||
* Per-AI-message metric: live rolling rate during that model-call segment,
|
||||
* frozen once the segment ends (not when the whole agent turn ends).
|
||||
*/
|
||||
export interface MessageMetric {
|
||||
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
||||
tokensPerSec: number;
|
||||
/**
|
||||
* Wall-clock duration from when the message first appeared with text to
|
||||
* either *now* (active) or to when the stream ended (frozen).
|
||||
* Wall-clock duration of this model-call segment: first visible character
|
||||
* → last visible character (tool-wait after the call is excluded).
|
||||
*/
|
||||
durationMs: number;
|
||||
/** True while this is the currently streaming message. */
|
||||
/** True while this message is still generating visible tokens. */
|
||||
isActive: boolean;
|
||||
/**
|
||||
* True while this message is the live generator but the rolling window
|
||||
* has seen no new characters — the pill should not keep a stale tok/s.
|
||||
*/
|
||||
isPaused: boolean;
|
||||
/**
|
||||
* Whether the speed value was computed from real usage_metadata output
|
||||
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
|
||||
@@ -45,15 +53,12 @@ export interface MessageMetric {
|
||||
isTurnFinal: boolean;
|
||||
}
|
||||
|
||||
type Sample = { at: number; chars: number };
|
||||
|
||||
type TimingEntry = {
|
||||
firstSeenAt: number;
|
||||
endedAt: number | null;
|
||||
samples: Sample[];
|
||||
lastGrewAt: number;
|
||||
samples: SpeedSample[];
|
||||
lastChars: number;
|
||||
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
|
||||
hadUsage: boolean;
|
||||
};
|
||||
|
||||
function isAiMessage(message: Message): boolean {
|
||||
@@ -82,11 +87,12 @@ function messageText(message: Message): string {
|
||||
*
|
||||
* Recording strategy:
|
||||
* - Whenever a message renders with text, we anchor `firstSeenAt`.
|
||||
* - On every text-length change we push a sample and recompute the rate.
|
||||
* - When `isLoading` flips to false we mark *all* still-active messages as
|
||||
* ended so subsequent renders return a frozen duration.
|
||||
* - Token totals come from usage_metadata when present (most accurate);
|
||||
* otherwise we fall back to char-count / 2.
|
||||
* - On every text-length change we push a sample.
|
||||
* - The segment freezes when this model call ends (next AI message, tool
|
||||
* calls, usage + idle, or the run stops) so tool-wait is not in the
|
||||
* denominator.
|
||||
* - Live rate is a rolling window that decays to 0 on pause.
|
||||
* - `usage_metadata.output_tokens` only corrects the frozen final value.
|
||||
*/
|
||||
export function useMessageMetrics(
|
||||
messages: Message[],
|
||||
@@ -104,7 +110,8 @@ export function useMessageMetrics(
|
||||
// synchronously inside an effect so React isn't tracking the map identity.
|
||||
useEffect(() => {
|
||||
const now = performance.now();
|
||||
let activeId: string | null = null;
|
||||
const grewIds = new Set<string>();
|
||||
let latestId: string | null = null;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
@@ -118,50 +125,62 @@ export function useMessageMetrics(
|
||||
entry = {
|
||||
firstSeenAt: now,
|
||||
endedAt: null,
|
||||
lastGrewAt: now,
|
||||
samples: [{ at: now, chars: text.length }],
|
||||
lastChars: text.length,
|
||||
hadUsage: false,
|
||||
};
|
||||
timingRef.current.set(id, entry);
|
||||
}
|
||||
|
||||
// Track the most recent AI message as the active one (while streaming).
|
||||
activeId = id;
|
||||
latestId = id;
|
||||
|
||||
// Sample only when actively streaming OR when content actually grew —
|
||||
// throttle to keep arrays bounded.
|
||||
const grew = text.length !== entry.lastChars;
|
||||
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
||||
entry.samples.push({ at: now, chars: text.length });
|
||||
entry.lastChars = text.length;
|
||||
if (entry.samples.length > 256) {
|
||||
entry.samples.splice(0, entry.samples.length - 256);
|
||||
if (grew) {
|
||||
grewIds.add(id);
|
||||
entry.lastGrewAt = now;
|
||||
if (now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
||||
pushSpeedSample(entry.samples, now, text.length);
|
||||
lastSampleAtRef.current = now;
|
||||
}
|
||||
lastSampleAtRef.current = now;
|
||||
} else if (grew) {
|
||||
// Still update lastChars so the next render captures it eventually.
|
||||
entry.lastChars = text.length;
|
||||
}
|
||||
|
||||
if (getUsageMetadata(message)) {
|
||||
entry.hadUsage = true;
|
||||
}
|
||||
}
|
||||
|
||||
// When streaming stops, freeze any non-ended entry to the current time.
|
||||
if (!isLoading) {
|
||||
for (const entry of timingRef.current.values()) {
|
||||
if (entry.endedAt === null) {
|
||||
entry.endedAt = now;
|
||||
}
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (!message || !isAiMessage(message)) continue;
|
||||
const id = getMessageId(message, i);
|
||||
const entry = timingRef.current.get(id);
|
||||
if (!entry) continue;
|
||||
|
||||
const grewThisTick = grewIds.has(id);
|
||||
if (grewThisTick && entry.endedAt != null && isLoading) {
|
||||
entry.endedAt = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const usage = getUsageMetadata(message);
|
||||
if (
|
||||
shouldFreezeGeneration({
|
||||
endedAt: entry.endedAt,
|
||||
isLoading,
|
||||
isLatestGenerating: id === latestId,
|
||||
hasToolCalls: messageHasToolCalls(message),
|
||||
grewThisTick,
|
||||
lastGrewAt: entry.lastGrewAt,
|
||||
now,
|
||||
outputTokens: usage?.outputTokens ?? 0,
|
||||
})
|
||||
) {
|
||||
entry.endedAt = entry.lastGrewAt;
|
||||
}
|
||||
}
|
||||
|
||||
lastActiveIdRef.current = activeId;
|
||||
lastActiveIdRef.current = latestId;
|
||||
setTick((v) => v + 1);
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Drive a slow tick while streaming so live durations refresh smoothly.
|
||||
// Drive a slow tick while streaming so live durations / pause decay refresh.
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
||||
@@ -198,45 +217,36 @@ export function useMessageMetrics(
|
||||
const entry = timingRef.current.get(id);
|
||||
if (!entry) continue;
|
||||
|
||||
const isActive =
|
||||
isLoading && id === lastActiveIdRef.current && entry.endedAt === null;
|
||||
const endedAt = entry.endedAt ?? now;
|
||||
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
|
||||
const isActive = isLoading && id === lastActiveIdRef.current;
|
||||
|
||||
let tokensPerSec = 0;
|
||||
let fromUsage = false;
|
||||
let isPaused = false;
|
||||
const usage = getUsageMetadata(message);
|
||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
|
||||
fromUsage = true;
|
||||
} else {
|
||||
// Char-count fallback. Uses the rolling window during streaming so a
|
||||
// mid-stream snapshot reflects current speed rather than average since
|
||||
// the start.
|
||||
if (isActive && entry.samples.length >= 2) {
|
||||
const samples = entry.samples;
|
||||
let start = samples.findIndex((s) => s.at >= now - 1500);
|
||||
if (start === -1) start = samples.length - 2;
|
||||
if (start < 0) start = 0;
|
||||
const head = samples[start];
|
||||
const tail = samples[samples.length - 1];
|
||||
if (head && tail) {
|
||||
const elapsedMs = tail.at - head.at;
|
||||
const chars = Math.max(0, tail.chars - head.chars);
|
||||
if (elapsedMs > 0) {
|
||||
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.endedAt != null) {
|
||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||
tokensPerSec = usageTokensPerSec(usage.outputTokens, durationMs);
|
||||
fromUsage = true;
|
||||
} else if (durationMs > 0) {
|
||||
// Finished message without usage data: average over full duration.
|
||||
const chars = entry.lastChars;
|
||||
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
|
||||
tokensPerSec = charsToTokensPerSec(entry.lastChars, durationMs);
|
||||
}
|
||||
} else {
|
||||
tokensPerSec = rollingTokensPerSec(entry.samples, entry.lastChars, now);
|
||||
isPaused =
|
||||
isActive &&
|
||||
tokensPerSec <= 0 &&
|
||||
now - entry.lastGrewAt >= GENERATION_IDLE_MS;
|
||||
}
|
||||
|
||||
out.set(id, {
|
||||
tokensPerSec,
|
||||
durationMs,
|
||||
isActive,
|
||||
isPaused,
|
||||
fromUsage,
|
||||
isTurnFinal: turnFinalIds.has(id),
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ export function ModelNameSelect({
|
||||
className,
|
||||
triggerClassName,
|
||||
defaultLabel = "默认模型",
|
||||
title = "选择用于本次操作的大模型(默认使用系统配置的模型)",
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (modelName: string | null) => void;
|
||||
@@ -32,6 +33,7 @@ export function ModelNameSelect({
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
defaultLabel?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const { models } = useModels();
|
||||
return (
|
||||
@@ -42,7 +44,7 @@ export function ModelNameSelect({
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("h-8 gap-1.5 text-xs", triggerClassName, className)}
|
||||
title="选择用于本次操作的大模型(默认使用系统配置的模型)"
|
||||
title={title}
|
||||
>
|
||||
<SelectValue placeholder={defaultLabel} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
FileText,
|
||||
MessageSquareText,
|
||||
Sparkles,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
MY_PROJECT_CONVERSATION_LABELS,
|
||||
type MyProjectConversationType,
|
||||
} from "@/core/threads/project-metadata";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BADGE_STYLES: Record<MyProjectConversationType, string> = {
|
||||
qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300",
|
||||
strategy:
|
||||
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300",
|
||||
solution:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300",
|
||||
};
|
||||
|
||||
const ICONS: Record<MyProjectConversationType, LucideIcon> = {
|
||||
qa: MessageSquareText,
|
||||
strategy: Sparkles,
|
||||
solution: FileText,
|
||||
};
|
||||
|
||||
const ICON_STYLES: Record<MyProjectConversationType, string> = {
|
||||
qa: "bg-sky-50 text-sky-600 dark:bg-sky-950/70 dark:text-sky-300",
|
||||
strategy:
|
||||
"bg-violet-50 text-violet-600 dark:bg-violet-950/70 dark:text-violet-300",
|
||||
solution:
|
||||
"bg-emerald-50 text-emerald-600 dark:bg-emerald-950/70 dark:text-emerald-300",
|
||||
};
|
||||
|
||||
export function MyProjectThreadIcon({
|
||||
type,
|
||||
className,
|
||||
}: {
|
||||
type: MyProjectConversationType;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = ICONS[type];
|
||||
const label = `我的项目 · ${MY_PROJECT_CONVERSATION_LABELS[type]}`;
|
||||
return (
|
||||
<span
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-flex size-5 shrink-0 items-center justify-center rounded-md",
|
||||
ICON_STYLES[type],
|
||||
className,
|
||||
)}
|
||||
role="img"
|
||||
title={label}
|
||||
>
|
||||
<Icon aria-hidden="true" className="size-3.5" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MyProjectThreadBadge({
|
||||
type,
|
||||
projectName,
|
||||
className,
|
||||
}: {
|
||||
type: MyProjectConversationType;
|
||||
projectName?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const typeLabel = `${MY_PROJECT_CONVERSATION_LABELS[type]}项目`;
|
||||
const ownerLabel = projectName?.trim() || "我的项目";
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex min-w-0 max-w-full items-center gap-1 self-start rounded border px-1.5 py-0.5 text-[9px] font-medium leading-none",
|
||||
BADGE_STYLES[type],
|
||||
className,
|
||||
)}
|
||||
title={`来自我的项目 · ${typeLabel} · ${ownerLabel}`}
|
||||
>
|
||||
<span className="shrink-0">{typeLabel}</span>
|
||||
<span aria-hidden="true" className="opacity-55">·</span>
|
||||
<span className="min-w-0 truncate">{ownerLabel}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
isHiddenFromUIMessage,
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
} from "@/core/messages/utils";
|
||||
|
||||
const SUMMARY_MAX_LENGTH = 80;
|
||||
|
||||
export type QuestionItem = {
|
||||
anchorId: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
/** Preserve the raw message index for historical messages without an id. */
|
||||
export function getQuestionAnchorId(message: Pick<Message, "id">, index: number): string {
|
||||
return `message-${message.id ?? index}`;
|
||||
}
|
||||
|
||||
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();
|
||||
return text.length > SUMMARY_MAX_LENGTH ? `${text.slice(0, SUMMARY_MAX_LENGTH)}…` : text;
|
||||
}
|
||||
|
||||
/** Index only real, visible questions, using the same visibility policy as chat. */
|
||||
export function buildQuestionItems(messages: Message[]): QuestionItem[] {
|
||||
const items: QuestionItem[] = [];
|
||||
messages.forEach((message, index) => {
|
||||
if (message.type !== "human" || isHiddenFromUIMessage(message)) return;
|
||||
const summary = summarizeQuestion(extractContentFromMessage(message));
|
||||
if (summary) items.push({ anchorId: getQuestionAnchorId(message, index), summary });
|
||||
});
|
||||
return items;
|
||||
}
|
||||
@@ -11,68 +11,21 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
} from "@/core/messages/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { buildQuestionItems, type QuestionItem } from "./question-navigator-data";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
export { getQuestionAnchorId } from "./question-navigator-data";
|
||||
|
||||
/** 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;
|
||||
@@ -128,16 +81,7 @@ export function QuestionNavigator({
|
||||
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 questions = useMemo(() => buildQuestionItems(messages), [messages]);
|
||||
|
||||
const visible = questions.length >= MIN_QUESTIONS;
|
||||
|
||||
|
||||
@@ -48,8 +48,13 @@ import {
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
MyProjectThreadBadge,
|
||||
MyProjectThreadIcon,
|
||||
} from "@/components/workspace/my-project-thread-indicator";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@@ -64,6 +69,8 @@ import {
|
||||
useToggleThreadPin,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
|
||||
import { myProjectConversationInfoOfThread } from "@/core/threads/project-metadata";
|
||||
import { useProjectThreadNames } from "@/core/threads/use-project-thread-names";
|
||||
import {
|
||||
isBootstrapThread,
|
||||
isHiddenFromRecentChats,
|
||||
@@ -78,6 +85,11 @@ import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { strategyProjectWorkbenchPath } from "@/strategy-selection/project-routes";
|
||||
import {
|
||||
mergeRecentWorkspaceItems,
|
||||
useRecentStrategyProjects,
|
||||
} from "@/strategy-selection/recent-projects";
|
||||
|
||||
export function RecentChatList({
|
||||
compact = false,
|
||||
@@ -93,6 +105,7 @@ export function RecentChatList({
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-chats-collapsed") === "true",
|
||||
);
|
||||
@@ -109,8 +122,9 @@ export function RecentChatList({
|
||||
useParams<{
|
||||
thread_id: string;
|
||||
agent_id?: string;
|
||||
}>();
|
||||
}>();
|
||||
const { data: threads = [] } = useThreads();
|
||||
const { data: recentStrategyProjects = [] } = useRecentStrategyProjects();
|
||||
const { mutate: deleteThread } = useDeleteThread();
|
||||
const { mutate: renameThread } = useRenameThread();
|
||||
const { toggle: togglePin } = useToggleThreadPin();
|
||||
@@ -134,15 +148,15 @@ export function RecentChatList({
|
||||
});
|
||||
if (threadIndex > -1) {
|
||||
if (threads[threadIndex + 1]) {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex + 1]!, pathname);
|
||||
} else if (threads[threadIndex - 1]) {
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
|
||||
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!, pathname);
|
||||
}
|
||||
}
|
||||
void router.push(nextThreadPath);
|
||||
}
|
||||
},
|
||||
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
|
||||
[agentIdFromPath, deleteThread, pathname, router, threadIdFromPath, threads],
|
||||
);
|
||||
|
||||
const handleRenameClick = useCallback(
|
||||
@@ -198,12 +212,16 @@ export function RecentChatList({
|
||||
const visibleThreads = sortThreadsPinnedFirst(
|
||||
threads.filter((thread) => !isHiddenFromRecentChats(thread)),
|
||||
);
|
||||
const projectThreadNames = useProjectThreadNames(visibleThreads);
|
||||
const recentItems = mergeRecentWorkspaceItems(
|
||||
visibleThreads,
|
||||
recentStrategyProjects,
|
||||
);
|
||||
|
||||
if (visibleThreads.length === 0) {
|
||||
if (recentItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const shownThreads =
|
||||
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
||||
const shownItems = limit != null ? recentItems.slice(0, limit) : recentItems;
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup
|
||||
@@ -225,7 +243,7 @@ export function RecentChatList({
|
||||
</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}
|
||||
{recentItems.length}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -242,32 +260,84 @@ export function RecentChatList({
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{shownThreads.map((thread) => {
|
||||
const href = listPathOfThread(thread);
|
||||
{shownItems.map((item) => {
|
||||
if (item.kind === "strategy-project") {
|
||||
const { project } = item;
|
||||
const href = strategyProjectWorkbenchPath(
|
||||
"/page/strategy/qa/projects",
|
||||
project.id,
|
||||
);
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={`strategy-project:${project.id}`}
|
||||
className="group/side-menu-item"
|
||||
>
|
||||
<SidebarMenuButton
|
||||
isActive={href === pathname}
|
||||
asChild
|
||||
className={cn(
|
||||
"h-auto min-h-11 rounded-lg px-3 py-1.5 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
|
||||
"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",
|
||||
compact &&
|
||||
"min-h-11 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={href}
|
||||
onClick={() => {
|
||||
if (isMobile) setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
<MyProjectThreadIcon type="strategy" />
|
||||
<span className="min-w-0 flex-1 flex flex-col items-start gap-0.5">
|
||||
<span className="w-full truncate">
|
||||
{desensitize(project.title)}
|
||||
</span>
|
||||
<MyProjectThreadBadge
|
||||
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
|
||||
projectName={project.title}
|
||||
type="strategy"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const { thread } = item;
|
||||
const href = listPathOfThread(thread, pathname);
|
||||
const isActive = href === pathname;
|
||||
const isBoot = isBootstrapThread(thread);
|
||||
const projectInfo = myProjectConversationInfoOfThread(
|
||||
thread,
|
||||
projectThreadNames,
|
||||
);
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={thread.thread_id}
|
||||
key={`thread:${thread.thread_id}`}
|
||||
className="group/side-menu-item"
|
||||
>
|
||||
<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" +
|
||||
(compact
|
||||
? " h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border"
|
||||
: "")
|
||||
}
|
||||
className={cn(
|
||||
"h-7 rounded-lg px-3 text-[13px] text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
|
||||
"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",
|
||||
compact &&
|
||||
"h-9 gap-2 rounded-md px-2.5 data-[active=true]:border data-[active=true]:border-border",
|
||||
projectInfo && "h-auto min-h-11 py-1.5",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center gap-1 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={href}
|
||||
onClick={() => { if (isMobile) setOpenMobile(false); }}
|
||||
>
|
||||
{isBoot ? (
|
||||
<Bot
|
||||
@@ -279,6 +349,8 @@ export function RecentChatList({
|
||||
}
|
||||
aria-label="智能体创建会话"
|
||||
/>
|
||||
) : projectInfo ? (
|
||||
<MyProjectThreadIcon type={projectInfo.type} />
|
||||
) : (
|
||||
compact && (
|
||||
<MessageSquare
|
||||
@@ -294,8 +366,22 @@ export function RecentChatList({
|
||||
{pinned && (
|
||||
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
projectInfo && "flex flex-col items-start gap-0.5",
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate">
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
</span>
|
||||
{projectInfo && (
|
||||
<MyProjectThreadBadge
|
||||
className="mr-3 max-w-[calc(100%_-_0.75rem)]"
|
||||
projectName={projectInfo.projectName}
|
||||
type={projectInfo.type}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
@@ -5,12 +5,15 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { OptType, writeYLog } from "@/core/log/y-log";
|
||||
import { clearYLogToken } from "@/core/log/y-log-token";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { PasswordSettings } from "./password-settings";
|
||||
|
||||
function roleLabel(role?: string) {
|
||||
if (role === "admin") return "管理员";
|
||||
@@ -57,14 +60,15 @@ export function AccountSettingsPage() {
|
||||
// Match the rest of the app's logout flow: blow away the cookie session
|
||||
// by hitting the gateway logout endpoint and then forcing a full reload
|
||||
// so any cached query state goes too.
|
||||
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
|
||||
void apiFetch(`${getBackendBaseURL()}/api/v1/auth/logout`, { method: "POST" })
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
window.location.assign("/");
|
||||
window.location.assign(`${window.location.pathname}#/login`);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
|
||||
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -106,6 +110,8 @@ export function AccountSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
<PasswordSettings key={auth.user_id} userId={auth.user_id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { useTheme, type ThemeType } from "@/contexts/ThemeContext";
|
||||
|
||||
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
|
||||
import { FONT_SCALE_MODE_OPTIONS, FONT_SCALE_OPTIONS } from "@/components/font-scale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -42,13 +42,20 @@ import { isIsolatedSession, setIsolatedSession } from "@/core/embed/embed-sessio
|
||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
useUpdateUserPreferences,
|
||||
useUserPreferencesQuery,
|
||||
} from "@/core/user-preferences";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useCitationDisplaySettings,
|
||||
useHtmlMapSettings,
|
||||
useLayoutMode,
|
||||
useUpdateAppearanceSettings,
|
||||
useUpdateCitationDisplaySettings,
|
||||
useUpdateHtmlMapSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import type { HtmlMapEngine } from "@/core/system-settings/api";
|
||||
import type {
|
||||
CompactShortcutButton,
|
||||
CompactShortcutRouteParam,
|
||||
@@ -179,12 +186,16 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const currentTheme = theme;
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const { data: userPreferences, isPending: preferencesLoading } = useUserPreferencesQuery();
|
||||
const updateUserPreferences = useUpdateUserPreferences();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const updateAppearance = useUpdateAppearanceSettings();
|
||||
const [shortcutDraft, setShortcutDraft] = useState<CompactShortcutButton[]>([]);
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||
const { data: htmlMap } = useHtmlMapSettings();
|
||||
const updateHtmlMap = useUpdateHtmlMapSettings();
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||
@@ -747,7 +758,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
|
||||
<SettingsSection
|
||||
title="字号"
|
||||
description="调整界面整体字号,方便放大或缩小阅读。默认 16px(标准)。"
|
||||
description="调整界面字号。默认 16px(标准)。「仅文字」只放大文字;「整页缩放」会连间距、图标和写死的字号一起变。"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Select
|
||||
@@ -770,6 +781,29 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{FONT_SCALE_MODE_OPTIONS.map((item) => {
|
||||
const selected = (settings.display.fontScaleMode ?? "text") === item.value;
|
||||
return (
|
||||
<Button
|
||||
key={item.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selected ? "default" : "outline"}
|
||||
onClick={() => setSettings("display", { fontScaleMode: item.value })}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{
|
||||
FONT_SCALE_MODE_OPTIONS.find(
|
||||
(item) => item.value === (settings.display.fontScaleMode ?? "text"),
|
||||
)?.description
|
||||
}
|
||||
</p>
|
||||
<div className="text-muted-foreground rounded-lg border p-4 text-sm leading-relaxed">
|
||||
预览:多智能体圆桌研讨 · The quick brown fox · 0123456789
|
||||
</div>
|
||||
@@ -831,6 +865,30 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
title="对话体验"
|
||||
description="问答过程中的辅助显示。"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<label htmlFor="followup-suggestions" className="text-sm font-medium">
|
||||
问答后推荐问题
|
||||
</label>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
默认关闭。开启后,在后续回答结束时生成并显示可继续提问的问题。仅对当前账号生效,设置随账号保存。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="followup-suggestions"
|
||||
checked={userPreferences?.followup_suggestions_enabled ?? false}
|
||||
disabled={preferencesLoading || updateUserPreferences.isPending}
|
||||
onCheckedChange={(checked) => {
|
||||
updateUserPreferences.mutate(
|
||||
{ followup_suggestions_enabled: checked },
|
||||
{
|
||||
onError: (error) => toast.error(error.message || "保存失败"),
|
||||
},
|
||||
);
|
||||
}}
|
||||
aria-label="问答后推荐问题"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">回答速度指示器</div>
|
||||
@@ -867,6 +925,53 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">HTML 地图</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
管理员配置。生成带地图的 HTML 网页时使用哪种地图。ECharts 使用本地行政区划;地图网页使用 Leaflet 瓦片,地址在后台 config.yaml 的 html_runtime.map_tile_url。下一次生成的页面生效。
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={htmlMap?.engine ?? "echarts"}
|
||||
disabled={!htmlMap || updateHtmlMap.isPending}
|
||||
onValueChange={(value) => {
|
||||
if (value !== "echarts" && value !== "leaflet") return;
|
||||
updateHtmlMap.mutate(value as HtmlMapEngine, {
|
||||
onError: (error) => toast.error(error.message || "保存失败"),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]" aria-label="HTML 地图">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="echarts">ECharts</SelectItem>
|
||||
<SelectItem value="leaflet">地图网页</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">技能检索文件展示</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
全局设置。开启后,问答步骤下展示技能检索到的文件,支持预览和下载;不展示生成文件。默认开启。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={citationDisplay?.retrieved_files_enabled ?? false}
|
||||
disabled={!citationDisplay || updateCitationDisplay.isPending}
|
||||
onCheckedChange={(checked) => updateCitationDisplay.mutate(
|
||||
{ retrieved_files_enabled: checked },
|
||||
{ onError: (error) => toast.error(error.message || "保存失败") },
|
||||
)}
|
||||
aria-label="技能检索文件展示"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{referenceModeEntryEnabled && (
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { AUTH_STATE_CHANGED_EVENT, getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
getAuthenticationSettings,
|
||||
saveAuthenticationSettings,
|
||||
type AuthenticationSettings,
|
||||
} from "@/core/auth/account-settings-api";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
export function AuthenticationSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const live = useLiveAccount();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["authentication-settings", live.userId];
|
||||
const query = useQuery({ queryKey, queryFn: getAuthenticationSettings, enabled: isAdmin });
|
||||
const [draft, setDraft] = useState<AuthenticationSettings | null>(null);
|
||||
const mutation = useMutation({ mutationFn: saveAuthenticationSettings });
|
||||
const settings = draft ?? query.data;
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const update = (key: keyof AuthenticationSettings, value: boolean) => {
|
||||
if (!settings) return;
|
||||
const next = { ...settings, [key]: value };
|
||||
if (key === "password_login_enabled" && !value) next.registration_enabled = false;
|
||||
setDraft(next);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!settings || mutation.isPending) return;
|
||||
try {
|
||||
const saved = await mutation.mutateAsync(settings);
|
||||
queryClient.setQueryData(queryKey, saved);
|
||||
setDraft(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["auth-options"] });
|
||||
window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT));
|
||||
toast.success("登录与注册设置已保存,即时生效");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const invalid = settings && !settings.password_login_enabled && !settings.sso_enabled;
|
||||
|
||||
return (
|
||||
<SettingsSection title="登录与注册" description="管理登录页面、自主注册与账号在线限制,保存后即时生效。">
|
||||
{query.isPending ? (
|
||||
<p className="text-muted-foreground text-sm">正在加载设置…</p>
|
||||
) : query.isError ? (
|
||||
<div role="alert" className="space-y-3 text-sm">
|
||||
<p className="text-destructive">{query.error.message}</p>
|
||||
<Button variant="outline" onClick={() => void query.refetch()}>重新加载</Button>
|
||||
</div>
|
||||
) : settings && (
|
||||
<div className="space-y-5">
|
||||
<div className="divide-y rounded-xl border px-5">
|
||||
<SettingSwitch id="password-login-enabled" title="账号密码登录" description="默认关闭。开启后须登录才能进入系统。未设置个人密码的账号使用现有系统口令。" checked={settings.password_login_enabled} disabled={mutation.isPending} onChange={(value) => update("password_login_enabled", value)} />
|
||||
<SettingSwitch id="registration-enabled" title="用户自主注册" description="允许用户在注册页创建自己的普通账号。需先开启账号密码登录。" checked={settings.registration_enabled} disabled={mutation.isPending || !settings.password_login_enabled} onChange={(value) => update("registration_enabled", value)} />
|
||||
<SettingSwitch id="single-session-enabled" title="单点登录" description="默认关闭。开启后同一账号只允许一处在线,新登录会使其他登录会话自动退出。" checked={settings.single_session_enabled ?? false} disabled={mutation.isPending} onChange={(value) => update("single_session_enabled", value)} />
|
||||
<SettingSwitch id="sso-enabled" title="外部系统免登" description="允许外部系统通过票据、令牌或口令链接登录,与单点登录的在线限制独立。" checked={settings.sso_enabled} disabled={mutation.isPending} onChange={(value) => update("sso_enabled", value)} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">密码与登录口令共用。至少保留账号密码登录或外部系统免登中的一种;关闭自主注册不影响已有账号。</p>
|
||||
<Button variant="outline" onClick={() => { onClose?.(); navigate("/page/strategy/admin/login-whitelist"); }}>管理用户密码与登录口令</Button>
|
||||
{invalid && <p role="alert" className="text-destructive text-sm">请至少开启账号密码登录或外部系统免登。</p>}
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={!draft || Boolean(invalid) || mutation.isPending} onClick={() => void save()}>{mutation.isPending ? "保存中…" : "保存设置"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingSwitch({ id, title, description, checked, disabled, onChange }: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-5 py-5">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={id} className="cursor-pointer text-sm font-medium">{title}</label>
|
||||
<p id={`${id}-description`} className="text-muted-foreground text-sm">{description}</p>
|
||||
</div>
|
||||
<Switch id={id} aria-describedby={`${id}-description`} checked={checked} disabled={disabled} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon, ShrinkIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useConversationContextSettings,
|
||||
useUpdateConversationContextSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export function ContextWindowSettingsPage() {
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useConversationContextSettings();
|
||||
const update = useUpdateConversationContextSettings();
|
||||
const enabled = data?.recent_turn_window_enabled ?? false;
|
||||
const recentTurns = data?.recent_turns ?? 3;
|
||||
|
||||
const handleChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ recent_turn_window_enabled: checked });
|
||||
toast.success(
|
||||
checked
|
||||
? `近 ${recentTurns} 轮上下文模式已开启`
|
||||
: `近 ${recentTurns} 轮上下文模式已关闭`,
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<ShrinkIcon className="size-5" />
|
||||
上下文管理
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
控制长对话发给模型的历史范围。该模式默认关闭,只有管理员显式开启后才会生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">仅保留最近 {recentTurns} 轮对话</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
开启后,主问答引擎的模型请求只携带最近 {recentTurns}
|
||||
个真实用户回合(含当前轮)及其回复和工具结果,不再调用摘要模型。完整聊天记录仍会保存并显示;若这三轮本身仍超过模型上限,最终安全限制会继续裁剪超长内容。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label={`仅保留最近 ${recentTurns} 轮对话`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-destructive text-sm">
|
||||
无法读取当前上下文模式,本页暂不能修改;服务端会继续保持原设置。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
{isFetching ? "重新加载中…" : "重新加载"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后优先于聊天框中的“上下文压缩”选项,并从下一次新问答或运行开始生效,无需重启服务。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon, SearchIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useOrdinaryKnowledgeRetrievalSettings,
|
||||
useUpdateOrdinaryKnowledgeRetrievalSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export function KnowledgeRetrievalSettingsPage() {
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useOrdinaryKnowledgeRetrievalSettings();
|
||||
const update = useUpdateOrdinaryKnowledgeRetrievalSettings();
|
||||
const enabled = data?.smart_reasoning_enabled ?? false;
|
||||
const advancedManagementEnabled =
|
||||
data?.advanced_management_enabled ?? false;
|
||||
|
||||
const handleChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ smart_reasoning_enabled: checked });
|
||||
toast.success(
|
||||
checked ? "普通知识库智能检索已开启" : "普通知识库智能检索已关闭",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdvancedManagementChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ advanced_management_enabled: checked });
|
||||
toast.success(
|
||||
checked ? "知识库高级管理入口已开放" : "知识库高级管理入口已隐藏",
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<SearchIcon className="size-5" />
|
||||
知识检索
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
控制普通知识库问答是否默认使用智能检索。该能力默认关闭,经典检索模式始终保留为候选。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">普通知识库智能推理检索</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
开启后,新对话默认使用智能检索:先召回原文分片,再根据命中线索查阅并扩展
|
||||
Wiki,把知识库资料作为回答背景。已有对话继续使用各自保存的模式,用户仍可在
|
||||
输入框的检索模式菜单中切换到经典检索。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label="普通知识库智能推理检索"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">知识库高级管理入口</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
默认隐藏检索测试、增量同步和知识库数据包导入导出。开启后按原有账号权限显示;
|
||||
基础知识库管理和查看 Wiki 索引始终可用。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={advancedManagementEnabled}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={handleAdvancedManagementChange}
|
||||
aria-label="知识库高级管理入口"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-destructive text-sm">
|
||||
无法读取当前知识检索设置,客户端按关闭处理,本页暂不能修改。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
{isFetching ? "重新加载中…" : "重新加载"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
用户只需直接提问,无需填写“先检索什么、再检索什么”的固定模板;系统不会向输入框
|
||||
注入检索提示词。智能检索将检索资料与模型原有能力结合后回答,不强制生成参考文献编号。
|
||||
关闭后不会删除历史对话;已保存为智能检索的线程需要切回经典模式后才能继续发送。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { BrainIcon, Loader2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useMemoryRuntimeSettings,
|
||||
useUpdateMemoryRuntimeSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export function MemoryRuntimeSettingsPage() {
|
||||
const { data, isLoading, isError } = useMemoryRuntimeSettings();
|
||||
const update = useUpdateMemoryRuntimeSettings();
|
||||
const enabled = data?.enabled ?? false;
|
||||
|
||||
const handleChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ enabled: checked });
|
||||
toast.success(checked ? "问答记忆已开启" : "问答记忆已关闭");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<BrainIcon className="size-5" />
|
||||
问答记忆
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
全局控制问答是否读取和写入长期记忆。系统默认永久关闭,只有管理员在这里显式开启后才会生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">启用问答记忆</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
关闭时不会加载记忆中间件、不会向提示词注入记忆,也不会在回答结束后调用模型提取记忆。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label="启用问答记忆"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
无法读取记忆开关,系统将按关闭处理。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
修改对下一次发起的问答生效;已生成的历史记忆文件不会被自动删除。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { getStoredAuth, setStoredAuth } from "@/core/auth";
|
||||
import { changeAccountPassword, getAccountPasswordStatus } from "@/core/auth/account-settings-api";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
export function PasswordSettings({ userId }: { userId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const status = useQuery({
|
||||
queryKey: ["account-password", userId],
|
||||
queryFn: getAccountPasswordStatus,
|
||||
});
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const hasPassword = status.data?.has_password ?? true;
|
||||
const requiresCurrentPassword = status.data?.requires_current_password ?? true;
|
||||
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (pending || !status.data) return;
|
||||
setError("");
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("两次输入的新密码不一致");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError("新密码至少需要 8 位,请避免常见弱密码");
|
||||
return;
|
||||
}
|
||||
const auth = getStoredAuth();
|
||||
if (!auth || auth.user_id !== userId) {
|
||||
setError("登录账号已变化,请重新打开个人信息");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
const session = await changeAccountPassword(requiresCurrentPassword ? currentPassword : "", newPassword);
|
||||
// Another tab may have changed the active account while the request was pending.
|
||||
if (getStoredAuth()?.access_token !== auth.access_token) return;
|
||||
if (session.access_token) {
|
||||
setStoredAuth({
|
||||
...auth,
|
||||
access_token: session.access_token,
|
||||
token_type: session.token_type ?? auth.token_type,
|
||||
expires_in: session.expires_in ?? auth.expires_in,
|
||||
needs_setup: false,
|
||||
});
|
||||
}
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
queryClient.setQueryData(["account-password", userId], { ...status.data, has_password: true, requires_current_password: true, uses_default_password: false });
|
||||
void queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
toast.success("密码与登录口令已同步更新,其他登录会话已失效");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "密码修改失败");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title={hasPassword ? "修改密码" : "设置密码"} description="密码与登录口令共用,修改后原密码和原口令链接失效。">
|
||||
{status.isPending ? <p className="text-muted-foreground text-sm">正在读取密码状态…</p> : status.isError ? (
|
||||
<div role="alert" className="space-y-3 text-sm">
|
||||
<p className="text-destructive">{status.error.message}</p>
|
||||
<Button variant="outline" onClick={() => void status.refetch()}>重新加载</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={(event) => void submit(event)} className="space-y-4 rounded-xl border p-5">
|
||||
{!hasPassword && <p className="text-muted-foreground text-sm">此账号尚未设置个人密码,当前使用系统口令。请先输入现有口令,再设置个人密码。</p>}
|
||||
{requiresCurrentPassword && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="account-current-password">当前密码 / 口令</label>
|
||||
<Input id="account-current-password" type="password" autoComplete="current-password" required value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} disabled={pending} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="account-new-password">新密码</label>
|
||||
<Input id="account-new-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} placeholder="至少 8 位,避免常见弱密码" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} disabled={pending} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="account-confirm-password">确认新密码</label>
|
||||
<Input id="account-confirm-password" type="password" autoComplete="new-password" required minLength={8} maxLength={255} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} disabled={pending} />
|
||||
</div>
|
||||
{error && <p role="alert" className="text-destructive text-sm">{error}</p>}
|
||||
<div className="flex justify-end"><Button type="submit" disabled={pending}>{pending ? "保存中…" : "保存密码"}</Button></div>
|
||||
</form>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { BellIcon, CpuIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import {
|
||||
BellIcon,
|
||||
BrainIcon,
|
||||
CpuIcon,
|
||||
FileOutputIcon,
|
||||
PaletteIcon,
|
||||
SearchIcon,
|
||||
ShieldCheckIcon,
|
||||
ShrinkIcon,
|
||||
SparklesIcon,
|
||||
UploadCloudIcon,
|
||||
UserIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -12,18 +25,29 @@ import {
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AuthenticationSettingsPage } from "@/components/workspace/settings/authentication-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { ContextWindowSettingsPage } from "@/components/workspace/settings/context-window-settings-page";
|
||||
import { KnowledgeRetrievalSettingsPage } from "@/components/workspace/settings/knowledge-retrieval-settings-page";
|
||||
import { MemoryRuntimeSettingsPage } from "@/components/workspace/settings/memory-runtime-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { UploadSettingsPage } from "@/components/workspace/settings/upload-settings-page";
|
||||
import { WriteFileCompatibilitySettingsPage } from "@/components/workspace/settings/write-file-compatibility-settings-page";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LocalEncoderSettingsCard } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
|
||||
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "authentication"
|
||||
| "appearance"
|
||||
| "conversation-context"
|
||||
| "knowledge-retrieval"
|
||||
| "memory"
|
||||
| "fileWriting"
|
||||
| "uploads"
|
||||
| "tools"
|
||||
| "encoder"
|
||||
| "about";
|
||||
@@ -35,6 +59,7 @@ type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
export function SettingsDialog(props: SettingsDialogProps) {
|
||||
const { defaultSection = "appearance", ...dialogProps } = props;
|
||||
const { t } = useI18n();
|
||||
const live = useLiveAccount();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<SettingsSection>(defaultSection);
|
||||
@@ -61,15 +86,19 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
// },
|
||||
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||
// 记忆已移至侧边栏入口 (/page/workspace/memory)
|
||||
// {
|
||||
// id: "memory",
|
||||
// label: t.settings.sections.memory,
|
||||
// icon: BrainIcon,
|
||||
// },
|
||||
...(isAdmin
|
||||
? [
|
||||
{ id: "conversation-context", label: "上下文管理", icon: ShrinkIcon },
|
||||
{ id: "knowledge-retrieval", label: "知识检索", icon: SearchIcon },
|
||||
{ id: "memory", label: "问答记忆", icon: BrainIcon },
|
||||
{ id: "fileWriting", label: "文件流式兼容", icon: FileOutputIcon },
|
||||
{ id: "uploads", label: "上传设置", icon: UploadCloudIcon },
|
||||
{ id: "authentication", label: "登录与注册", icon: ShieldCheckIcon },
|
||||
]
|
||||
: []),
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
...(isAdmin
|
||||
? [{ id: "encoder", label: "本地编码器", icon: CpuIcon }]
|
||||
? [{ id: "encoder", label: "Wiki 镜像编码器", icon: CpuIcon }]
|
||||
: []),
|
||||
{
|
||||
id: "account",
|
||||
@@ -129,12 +158,36 @@ 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 onClose={() => props.onOpenChange?.(false)} />
|
||||
{activeSection === "authentication" && isAdmin && (
|
||||
<AuthenticationSettingsPage
|
||||
key={live.userId}
|
||||
onClose={() => props.onOpenChange?.(false)}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "appearance" && (
|
||||
<AppearanceSettingsPage
|
||||
onClose={() => props.onOpenChange?.(false)}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "conversation-context" && isAdmin && (
|
||||
<ContextWindowSettingsPage />
|
||||
)}
|
||||
{activeSection === "knowledge-retrieval" && isAdmin && (
|
||||
<KnowledgeRetrievalSettingsPage />
|
||||
)}
|
||||
{activeSection === "memory" && isAdmin && (
|
||||
<MemoryRuntimeSettingsPage />
|
||||
)}
|
||||
{activeSection === "fileWriting" && isAdmin && (
|
||||
<WriteFileCompatibilitySettingsPage />
|
||||
)}
|
||||
{activeSection === "uploads" && isAdmin && (
|
||||
<UploadSettingsPage />
|
||||
)}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "encoder" && isAdmin && <LocalEncoderSettingsCard />}
|
||||
{activeSection === "encoder" && isAdmin && (
|
||||
<LocalEncoderSettingsCard />
|
||||
)}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
LockIcon,
|
||||
PinIcon,
|
||||
PlusIcon,
|
||||
Power,
|
||||
PowerOff,
|
||||
RefreshCwIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
@@ -70,11 +72,13 @@ import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { MINIMAL_SKILL_MD_EXAMPLE } from "@/core/skill-example";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteBuiltinSkill,
|
||||
useDeleteCustomSkill,
|
||||
useDuplicateSkill,
|
||||
useEnableSkill,
|
||||
useGenerateMissingSkillNameZh,
|
||||
useGenerateSkillNameZh,
|
||||
useInstallSkillUpload,
|
||||
@@ -87,11 +91,23 @@ import {
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
import {
|
||||
canToggleSkillAvailability,
|
||||
isSkillOffline,
|
||||
type SkillAvailabilityControl,
|
||||
} from "@/core/skill-availability";
|
||||
import {
|
||||
usePublishSquares,
|
||||
useSkillCompressionSettings,
|
||||
useViewMode,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import {
|
||||
WX_RESTRICTED_TAB_DEFAULT_SQUARE,
|
||||
WX_RESTRICTED_TAB_NANJING_DB,
|
||||
isWxRestrictedPlaceholderTab,
|
||||
wxRestrictedDefaultSquareTabValue,
|
||||
wxRestrictedNanjingTabValue,
|
||||
} from "@/core/system-settings/wx-type-filter";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import type { SkillKnowledgeBinding } from "@/strategy-components/api/skill-knowledge";
|
||||
import { formatDateTime } from "@/core/utils/datetime";
|
||||
@@ -105,18 +121,25 @@ import { SkillStateSheet } from "./skill-state-sheet";
|
||||
export function SkillSettingsPage({
|
||||
onClose,
|
||||
embedded = false,
|
||||
restrictWxTypes = false,
|
||||
}: {
|
||||
onClose?: () => void;
|
||||
embedded?: boolean;
|
||||
/** wx 侧栏入口:类型 Tab 只保留 我的 / 默认广场 / 南京-db。 */
|
||||
restrictWxTypes?: boolean;
|
||||
} = {}) {
|
||||
const { t } = useI18n();
|
||||
if (embedded) return <SkillSettingsList onClose={onClose} />;
|
||||
if (embedded) {
|
||||
return (
|
||||
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.skills.title}
|
||||
description={t.settings.skills.description}
|
||||
>
|
||||
<SkillSettingsList onClose={onClose} />
|
||||
<SkillSettingsList onClose={onClose} restrictWxTypes={restrictWxTypes} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -165,7 +188,13 @@ const rescanErrorToastDarkOptions = {
|
||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||
} as const;
|
||||
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
function SkillSettingsList({
|
||||
onClose,
|
||||
restrictWxTypes = false,
|
||||
}: {
|
||||
onClose?: () => void;
|
||||
restrictWxTypes?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
@@ -187,6 +216,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
// Each configured publish square (发布广场) becomes its own browse tab; the
|
||||
// backend always returns at least one (the built-in default).
|
||||
const squareTabs = publishSquares?.squares ?? [];
|
||||
const defaultSquareTabValue = wxRestrictedDefaultSquareTabValue(
|
||||
publishSquares?.default_square_id,
|
||||
);
|
||||
const nanjingTabValue = wxRestrictedNanjingTabValue(squareTabs);
|
||||
const reconcileMutation = useReconcileCustomSkills();
|
||||
const fillNamesMutation = useGenerateMissingSkillNameZh();
|
||||
// Optional model override for the admin "一键补充中文名" action (null = default).
|
||||
@@ -253,6 +286,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const isSquareTab = filter !== "mine";
|
||||
const scopedSkills = filter === "mine" ? activeMine : activeAll;
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (restrictWxTypes && isWxRestrictedPlaceholderTab(filter)) {
|
||||
return [];
|
||||
}
|
||||
let list = publisher
|
||||
? scopedSkills.filter((s) => s.owner_user_id === publisher.id)
|
||||
: scopedSkills;
|
||||
@@ -266,7 +302,14 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [scopedSkills, publisher, filter, isSquareTab, publishSquares?.default_square_id]);
|
||||
}, [
|
||||
scopedSkills,
|
||||
publisher,
|
||||
filter,
|
||||
isSquareTab,
|
||||
publishSquares?.default_square_id,
|
||||
restrictWxTypes,
|
||||
]);
|
||||
const orderedSkills = useMemo(
|
||||
() =>
|
||||
filteredSkills
|
||||
@@ -296,6 +339,30 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
// If the active square tab disappears (admin removed that square), fall back
|
||||
// to "我的" so the page never points at a dead tab.
|
||||
useEffect(() => {
|
||||
if (restrictWxTypes) {
|
||||
if (
|
||||
filter === WX_RESTRICTED_TAB_NANJING_DB &&
|
||||
nanjingTabValue !== WX_RESTRICTED_TAB_NANJING_DB
|
||||
) {
|
||||
setFilter(nanjingTabValue);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
filter === WX_RESTRICTED_TAB_DEFAULT_SQUARE &&
|
||||
defaultSquareTabValue !== WX_RESTRICTED_TAB_DEFAULT_SQUARE
|
||||
) {
|
||||
setFilter(defaultSquareTabValue);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
filter !== "mine" &&
|
||||
filter !== defaultSquareTabValue &&
|
||||
filter !== nanjingTabValue
|
||||
) {
|
||||
setFilter("mine");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
filter !== "mine" &&
|
||||
squareTabs.length > 0 &&
|
||||
@@ -303,7 +370,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
) {
|
||||
setFilter("mine");
|
||||
}
|
||||
}, [filter, squareTabs]);
|
||||
}, [filter, squareTabs, restrictWxTypes, defaultSquareTabValue, nanjingTabValue]);
|
||||
const pagedSkills = useMemo(
|
||||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[orderedSkills, page],
|
||||
@@ -354,7 +421,6 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
theme === "dark" ? rescanErrorToastDarkOptions : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
@@ -362,12 +428,22 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={filter} onValueChange={setFilter}>
|
||||
<TabsList variant="line" className="flex-wrap">
|
||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||
{squareTabs.map((sq) => (
|
||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||
{sq.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{restrictWxTypes ? (
|
||||
<>
|
||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||
<TabsTrigger value={defaultSquareTabValue}>默认广场</TabsTrigger>
|
||||
<TabsTrigger value={nanjingTabValue}>南京-db</TabsTrigger>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||
{squareTabs.map((sq) => (
|
||||
<TabsTrigger key={sq.id} value={sq.id} title={sq.description || sq.name}>
|
||||
{sq.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
@@ -549,6 +625,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
availabilityControl="owner"
|
||||
layout={viewMode === "card" ? "card" : "row"}
|
||||
onPublisherClick={setPublisher}
|
||||
/>
|
||||
@@ -607,6 +684,7 @@ export function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
availabilityControl,
|
||||
layout = "row",
|
||||
onPublisherClick,
|
||||
knowledgeBindings = [],
|
||||
@@ -614,6 +692,8 @@ export function SkillRow({
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
isAdmin: boolean;
|
||||
/** Which permission boundary exposes the global online/offline control. */
|
||||
availabilityControl?: SkillAvailabilityControl;
|
||||
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
||||
layout?: "row" | "card";
|
||||
/** Click the 发布者 line to filter the list to that publisher. Receives the
|
||||
@@ -629,6 +709,7 @@ export function SkillRow({
|
||||
const setPinned = useSetSkillPinned();
|
||||
const setFeatured = useSetSkillFeatured();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const setEnabled = useEnableSkill();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
const deleteCustom = useDeleteCustomSkill();
|
||||
const deleteBuiltin = useDeleteBuiltinSkill();
|
||||
@@ -639,6 +720,7 @@ export function SkillRow({
|
||||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||
const [duplicateName, setDuplicateName] = useState("");
|
||||
const [publishPickerOpen, setPublishPickerOpen] = useState(false);
|
||||
const [confirmOfflineOpen, setConfirmOfflineOpen] = useState(false);
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||
@@ -653,6 +735,27 @@ export function SkillRow({
|
||||
const canTogglePublish = canManage && !isBuiltin;
|
||||
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
||||
const canSetAlwaysOn = isAdmin || isOwned;
|
||||
const canToggleAvailability = canToggleSkillAvailability({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
control: availabilityControl,
|
||||
});
|
||||
const isOffline = isSkillOffline(skill);
|
||||
|
||||
async function handleSetEnabled(enabled: boolean) {
|
||||
try {
|
||||
await setEnabled.mutateAsync({ skillName: skill.name, enabled });
|
||||
toast.success(
|
||||
enabled
|
||||
? t.settings.skills.onlineSuccess
|
||||
: t.settings.skills.offlineSuccess,
|
||||
);
|
||||
setConfirmOfflineOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAlwaysOn(next: boolean) {
|
||||
try {
|
||||
@@ -883,6 +986,16 @@ export function SkillRow({
|
||||
{knowledgeBindings.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">+{knowledgeBindings.length - 3}</Badge>
|
||||
)}
|
||||
{isOffline && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-red-500/35 bg-red-500/5 text-red-700 dark:text-red-300 text-xs"
|
||||
title={t.settings.skills.badgeOfflineTitle}
|
||||
>
|
||||
<PowerOff className="size-3" />
|
||||
{t.settings.skills.badgeOffline}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -941,6 +1054,43 @@ export function SkillRow({
|
||||
|
||||
const actionButtons = (
|
||||
<>
|
||||
{canToggleAvailability && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isOffline ? "default" : "outline"}
|
||||
className={cn(
|
||||
"h-8 shrink-0 gap-1.5 px-2.5",
|
||||
!isOffline && "text-destructive hover:text-destructive",
|
||||
)}
|
||||
disabled={
|
||||
setEnabled.isPending ||
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"
|
||||
}
|
||||
onClick={() => {
|
||||
if (isOffline) {
|
||||
void handleSetEnabled(true);
|
||||
} else {
|
||||
setConfirmOfflineOpen(true);
|
||||
}
|
||||
}}
|
||||
title={
|
||||
isOffline
|
||||
? t.settings.skills.onlineAction
|
||||
: t.settings.skills.offlineAction
|
||||
}
|
||||
>
|
||||
{setEnabled.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : isOffline ? (
|
||||
<Power className="size-3.5" />
|
||||
) : (
|
||||
<PowerOff className="size-3.5" />
|
||||
)}
|
||||
{isOffline
|
||||
? t.settings.skills.onlineAction
|
||||
: t.settings.skills.offlineAction}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -1060,7 +1210,12 @@ export function SkillRow({
|
||||
return (
|
||||
<>
|
||||
{isCard ? (
|
||||
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
|
||||
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
@@ -1076,7 +1231,13 @@ export function SkillRow({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||
<Item
|
||||
className={cn(
|
||||
"w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white",
|
||||
isOffline && "bg-muted/60 border-dashed dark:bg-[#071D36]",
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
@@ -1157,6 +1318,37 @@ export function SkillRow({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={confirmOfflineOpen} onOpenChange={setConfirmOfflineOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.settings.skills.offlineConfirmTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.settings.skills.offlineConfirmDescription.replace(
|
||||
"{name}",
|
||||
desensitize(skill.name),
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmOfflineOpen(false)}
|
||||
disabled={setEnabled.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleSetEnabled(false)}
|
||||
disabled={setEnabled.isPending}
|
||||
>
|
||||
{setEnabled.isPending
|
||||
? t.common.loading
|
||||
: t.settings.skills.offlineAction}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
@@ -1372,6 +1564,9 @@ function InstallSkillDialog({
|
||||
const canOverwrite = !!validation && !validationError && validation.can_overwrite;
|
||||
const ownCustomCanOverwrite = !!isCustomConflict && !!validation?.owned_by_me && canOverwrite;
|
||||
const otherCustomConflict = !!isCustomConflict && !!validation?.owned_by_other;
|
||||
// On-disk leftover with no ownership record: replaceable by whoever uploads it.
|
||||
const unclaimedCustomConflict =
|
||||
!!isCustomConflict && !validation?.owned_by_me && !validation?.owned_by_other && canOverwrite;
|
||||
const customConflictMessage = !validation
|
||||
? ""
|
||||
: otherCustomConflict
|
||||
@@ -1386,6 +1581,8 @@ function InstallSkillDialog({
|
||||
? dialog.conflictOwnedByMe
|
||||
: dialog.conflictOwnedByMeWrongTarget
|
||||
).replace("{name}", validation.skill_name)
|
||||
: unclaimedCustomConflict
|
||||
? dialog.conflictUnclaimed.replace("{name}", validation.skill_name)
|
||||
: dialog.conflictCustom.replace("{name}", validation.skill_name);
|
||||
|
||||
return (
|
||||
@@ -1496,6 +1693,16 @@ function InstallSkillDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/30 p-3">
|
||||
<div className="text-sm font-medium">{dialog.exampleTitle}</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{dialog.exampleHint}
|
||||
</p>
|
||||
<pre className="mt-2 overflow-x-auto rounded bg-background px-3 py-2 text-xs leading-5">
|
||||
<code>{MINIMAL_SKILL_MD_EXAMPLE}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
|
||||
@@ -1572,7 +1779,7 @@ function InstallSkillDialog({
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-start gap-2 text-xs",
|
||||
ownCustomCanOverwrite ? "text-amber-600" : "text-destructive",
|
||||
ownCustomCanOverwrite || unclaimedCustomConflict ? "text-amber-600" : "text-destructive",
|
||||
)}
|
||||
>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Loader2Icon,
|
||||
RotateCcwIcon,
|
||||
SaveIcon,
|
||||
UploadCloudIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DEFAULT_UPLOAD_POLICY,
|
||||
MEBIBYTE,
|
||||
normalizeUploadExtension,
|
||||
type UploadPolicy,
|
||||
type UploadPolicyGroupKey,
|
||||
useResetUploadPolicy,
|
||||
useUpdateUploadPolicy,
|
||||
useUploadPolicy,
|
||||
} from "@/core/uploads";
|
||||
|
||||
type UploadPolicyDraft = {
|
||||
maxFiles: string;
|
||||
maxTotalMiB: string;
|
||||
groups: Record<
|
||||
UploadPolicyGroupKey,
|
||||
{ maxMiB: string; extensions: string }
|
||||
>;
|
||||
thresholdMiB: string;
|
||||
maxResultChars: string;
|
||||
hardMaxResultChars: string;
|
||||
};
|
||||
|
||||
const GROUP_META: Array<{
|
||||
key: UploadPolicyGroupKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{ key: "document", label: "文档", description: "Word、PPT、TXT、PDF 等" },
|
||||
{ key: "image", label: "图片", description: "JPG、PNG 等" },
|
||||
{ key: "spreadsheet", label: "表格", description: "Excel 等" },
|
||||
];
|
||||
|
||||
function toDraft(policy: UploadPolicy): UploadPolicyDraft {
|
||||
return {
|
||||
maxFiles: String(policy.max_files_per_message),
|
||||
maxTotalMiB: String(policy.max_total_bytes / MEBIBYTE),
|
||||
groups: {
|
||||
document: {
|
||||
maxMiB: String(policy.groups.document.max_bytes / MEBIBYTE),
|
||||
extensions: policy.groups.document.extensions.join(", "),
|
||||
},
|
||||
image: {
|
||||
maxMiB: String(policy.groups.image.max_bytes / MEBIBYTE),
|
||||
extensions: policy.groups.image.extensions.join(", "),
|
||||
},
|
||||
spreadsheet: {
|
||||
maxMiB: String(policy.groups.spreadsheet.max_bytes / MEBIBYTE),
|
||||
extensions: policy.groups.spreadsheet.extensions.join(", "),
|
||||
},
|
||||
},
|
||||
thresholdMiB: String(policy.large_file.threshold_bytes / MEBIBYTE),
|
||||
maxResultChars: String(policy.large_file.max_result_chars),
|
||||
hardMaxResultChars: String(policy.large_file.hard_max_result_chars),
|
||||
};
|
||||
}
|
||||
|
||||
function positiveNumber(value: string, label: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`${label}必须大于 0`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function extensionsOf(value: string, label: string): string[] {
|
||||
const extensions = Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(/[,,\s]+/)
|
||||
.map(normalizeUploadExtension)
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
if (extensions.length === 0) throw new Error(`${label}至少填写一个扩展名`);
|
||||
return extensions;
|
||||
}
|
||||
|
||||
function fromDraft(draft: UploadPolicyDraft): UploadPolicy {
|
||||
const maxResultChars = Math.floor(
|
||||
positiveNumber(draft.maxResultChars, "常规输出上限"),
|
||||
);
|
||||
const hardMaxResultChars = Math.floor(
|
||||
positiveNumber(draft.hardMaxResultChars, "硬输出上限"),
|
||||
);
|
||||
if (maxResultChars > hardMaxResultChars) {
|
||||
throw new Error("常规输出上限不能大于硬输出上限");
|
||||
}
|
||||
|
||||
const groups = {} as UploadPolicy["groups"];
|
||||
for (const { key, label } of GROUP_META) {
|
||||
groups[key] = {
|
||||
max_bytes: Math.floor(
|
||||
positiveNumber(draft.groups[key].maxMiB, `${label}单文件上限`) *
|
||||
MEBIBYTE,
|
||||
),
|
||||
extensions: extensionsOf(draft.groups[key].extensions, label),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
max_files_per_message: Math.floor(
|
||||
positiveNumber(draft.maxFiles, "单条消息文件数"),
|
||||
),
|
||||
max_total_bytes: Math.floor(
|
||||
positiveNumber(draft.maxTotalMiB, "附件总大小") * MEBIBYTE,
|
||||
),
|
||||
groups,
|
||||
large_file: {
|
||||
threshold_bytes: Math.floor(
|
||||
positiveNumber(draft.thresholdMiB, "大文件阈值") * MEBIBYTE,
|
||||
),
|
||||
max_result_chars: maxResultChars,
|
||||
hard_max_result_chars: hardMaxResultChars,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function UploadSettingsPage() {
|
||||
const { data, isLoading } = useUploadPolicy();
|
||||
const update = useUpdateUploadPolicy();
|
||||
const reset = useResetUploadPolicy();
|
||||
const [draft, setDraft] = useState<UploadPolicyDraft>(() =>
|
||||
toDraft(DEFAULT_UPLOAD_POLICY),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) setDraft(toDraft(data));
|
||||
}, [data]);
|
||||
|
||||
const setGroup = (
|
||||
key: UploadPolicyGroupKey,
|
||||
patch: Partial<UploadPolicyDraft["groups"][UploadPolicyGroupKey]>,
|
||||
) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
groups: {
|
||||
...current.groups,
|
||||
[key]: { ...current.groups[key], ...patch },
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const saved = await update.mutateAsync(fromDraft(draft));
|
||||
setDraft(toDraft(saved));
|
||||
toast.success("上传设置已保存");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存上传设置失败");
|
||||
}
|
||||
};
|
||||
|
||||
const restoreDefaults = async () => {
|
||||
if (!window.confirm("确定恢复系统默认上传策略吗?当前自定义设置会被覆盖。")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const saved = await reset.mutateAsync();
|
||||
setDraft(toDraft(saved));
|
||||
toast.success("已恢复默认上传设置");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "恢复默认上传设置失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<UploadCloudIcon className="size-5" />
|
||||
上传设置
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
控制聊天附件的数量、格式和大小,并限制大文件读取结果,防止文件内容一次性占满模型上下文。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 rounded-lg border p-4 sm:grid-cols-2">
|
||||
<NumberField
|
||||
id="upload-max-files"
|
||||
label="单条消息最多文件数"
|
||||
value={draft.maxFiles}
|
||||
onChange={(maxFiles) => setDraft((value) => ({ ...value, maxFiles }))}
|
||||
step="1"
|
||||
/>
|
||||
<NumberField
|
||||
id="upload-max-total"
|
||||
label="单条消息附件总上限(MiB)"
|
||||
value={draft.maxTotalMiB}
|
||||
onChange={(maxTotalMiB) =>
|
||||
setDraft((value) => ({ ...value, maxTotalMiB }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">文件分组</h4>
|
||||
{GROUP_META.map(({ key, label, description }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid gap-4 rounded-lg border p-4 sm:grid-cols-[180px_minmax(0,1fr)]"
|
||||
>
|
||||
<NumberField
|
||||
id={`upload-${key}-size`}
|
||||
label={`${label}单文件上限(MiB)`}
|
||||
value={draft.groups[key].maxMiB}
|
||||
onChange={(maxMiB) => setGroup(key, { maxMiB })}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor={`upload-${key}-extensions`}
|
||||
>
|
||||
{label}扩展名
|
||||
</label>
|
||||
<Input
|
||||
id={`upload-${key}-extensions`}
|
||||
value={draft.groups[key].extensions}
|
||||
onChange={(event) =>
|
||||
setGroup(key, { extensions: event.currentTarget.value })
|
||||
}
|
||||
placeholder=".doc, .docx"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{description};用逗号或空格分隔,保存时自动补全小数点。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">大文件读取保护</h4>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
达到阈值后使用头部、中部、尾部采样;输出始终受字符预算约束,并在界面中标明截断或采样。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<NumberField
|
||||
id="upload-large-threshold"
|
||||
label="大文件阈值(MiB)"
|
||||
value={draft.thresholdMiB}
|
||||
onChange={(thresholdMiB) =>
|
||||
setDraft((value) => ({ ...value, thresholdMiB }))
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
id="upload-result-chars"
|
||||
label="常规输出上限(字符)"
|
||||
value={draft.maxResultChars}
|
||||
onChange={(maxResultChars) =>
|
||||
setDraft((value) => ({ ...value, maxResultChars }))
|
||||
}
|
||||
step="100"
|
||||
/>
|
||||
<NumberField
|
||||
id="upload-hard-result-chars"
|
||||
label="硬输出上限(字符)"
|
||||
value={draft.hardMaxResultChars}
|
||||
onChange={(hardMaxResultChars) =>
|
||||
setDraft((value) => ({ ...value, hardMaxResultChars }))
|
||||
}
|
||||
step="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{isLoading && (
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<Loader2Icon className="size-3.5 animate-spin" /> 正在读取配置
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void restoreDefaults()}
|
||||
disabled={update.isPending || reset.isPending}
|
||||
>
|
||||
{reset.isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcwIcon className="size-4" />
|
||||
)}
|
||||
恢复默认
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void save()}
|
||||
disabled={update.isPending || reset.isPending}
|
||||
>
|
||||
{update.isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SaveIcon className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
step = "0.1",
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
step?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min="0.01"
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { FileOutputIcon, Loader2Icon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useUpdateWriteFileCompatibilitySettings,
|
||||
useWriteFileCompatibilitySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export function WriteFileCompatibilitySettingsPage() {
|
||||
const { data, isLoading, isError } = useWriteFileCompatibilitySettings();
|
||||
const update = useUpdateWriteFileCompatibilitySettings();
|
||||
const enabled = data?.enabled ?? false;
|
||||
|
||||
const handleChange = async (checked: boolean) => {
|
||||
try {
|
||||
await update.mutateAsync({ enabled: checked });
|
||||
toast.success(checked ? "文件流式兼容已开启" : "文件流式兼容已关闭");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold">
|
||||
<FileOutputIcon className="size-5" />
|
||||
文件流式兼容
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
临时兼容会缓存长工具参数的内网模型网关。开启后,所有兼容模型仍调用原有
|
||||
write_file 工具,但文件正文改由普通文本流生成,再投影为标准 write_file
|
||||
流式事件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6 rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">所有模型启用兼容写入</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
适用于当前配置的 OpenAI-compatible Chat Completions
|
||||
模型。关闭后恢复各模型原有写入方式。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="text-muted-foreground size-5 animate-spin" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={isError || update.isPending}
|
||||
onCheckedChange={handleChange}
|
||||
aria-label="所有模型启用文件流式兼容"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<p className="text-destructive text-sm">
|
||||
无法读取文件流式兼容开关,系统将按关闭处理。
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
保存后无需重启后台,从下一次发起的问答开始生效;正在生成中的回答不会中途切换。
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,17 @@ import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } fr
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import {
|
||||
resolveWeKnoraSourceTarget,
|
||||
type WeKnoraSourceTarget,
|
||||
} from "@/core/llmwiki/source-links";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
@@ -56,6 +59,9 @@ export function SourceCard({
|
||||
}) {
|
||||
const [relatedOpen, setRelatedOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [linkedTarget, setLinkedTarget] = useState<WeKnoraSourceTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const previewTarget = resolveWeKnoraSourceTarget({
|
||||
@@ -65,7 +71,13 @@ export function SourceCard({
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||
const isWikiIndexSource =
|
||||
source.type === "llmwiki_wiki_index_intro" ||
|
||||
source.raw?.type === "llmwiki_wiki_index_intro";
|
||||
const isWikiSource =
|
||||
Boolean(previewTarget?.wikiSlug) ||
|
||||
source.type === "llmwiki_wiki" ||
|
||||
source.type === "llmwiki_wiki_page";
|
||||
const matchedHeading = isWikiSource
|
||||
? String(
|
||||
(source.raw?.matched_heading as string | undefined) ||
|
||||
@@ -78,13 +90,17 @@ export function SourceCard({
|
||||
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||
? source.raw.summary
|
||||
: "";
|
||||
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
? `${Math.round(source.score * 100)}%`
|
||||
: String(source.score)
|
||||
: null;
|
||||
const snippet = plainTextReferencePreview(content || summary).slice(0, 280);
|
||||
const scoreSemantics = typeof source.raw?.rank_score_semantics === "string"
|
||||
? source.raw.rank_score_semantics
|
||||
: "";
|
||||
const scoreMeta = typeof source.score !== "number"
|
||||
? null
|
||||
: scoreSemantics === "rrf"
|
||||
? `融合排序分 ${source.score.toFixed(4)}`
|
||||
: scoreSemantics === "provider_relevance"
|
||||
? `上游通道分 ${source.score.toFixed(4)}`
|
||||
: `相关度 ${source.score <= 1 ? `${Math.round(source.score * 100)}%` : String(source.score)}`;
|
||||
const meta = [
|
||||
source.source,
|
||||
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||
@@ -92,7 +108,7 @@ export function SourceCard({
|
||||
source.author ? `作者 ${source.author}` : null,
|
||||
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
||||
source.time,
|
||||
score ? `相关度 ${score}` : null,
|
||||
scoreMeta,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = isCitation;
|
||||
@@ -151,7 +167,7 @@ export function SourceCard({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(source.url || isWikiSource) && (
|
||||
{(source.url || isWikiSource || isWikiIndexSource) && (
|
||||
previewTarget ? (
|
||||
<span className={cn(
|
||||
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
|
||||
@@ -162,30 +178,47 @@ export function SourceCard({
|
||||
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
|
||||
{isWikiSource ? "Wiki 文档" : "知识库文档"}
|
||||
</span>
|
||||
) : isWikiIndexSource ? (
|
||||
<span className="mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md bg-emerald-100 px-1.5 py-1 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||
<BookOpenTextIcon className="size-3.5" />
|
||||
Wiki 索引
|
||||
</span>
|
||||
) : (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{previewTarget && (
|
||||
{(previewTarget || isWikiIndexSource) && (
|
||||
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
||||
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
|
||||
<span>
|
||||
{isWikiIndexSource
|
||||
? "点击卡片查看完整 Wiki 索引"
|
||||
: isWikiSource
|
||||
? "点击卡片查看完整 Wiki"
|
||||
: "点击卡片查看详情"}
|
||||
</span>
|
||||
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{previewTarget && (
|
||||
{(previewTarget || linkedTarget) && (
|
||||
<WeKnoraSourceDrawer
|
||||
open={detailOpen}
|
||||
target={previewTarget}
|
||||
target={linkedTarget ?? previewTarget}
|
||||
title={source.title}
|
||||
quote={content}
|
||||
onOpenChange={setDetailOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDetailOpen(open);
|
||||
if (!open) setLinkedTarget(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
{!previewTarget && <Sheet open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(92vw,760px)] gap-0 overflow-hidden p-0 sm:max-w-none"
|
||||
>
|
||||
<SheetHeader className="shrink-0 border-b px-5 py-4 pr-12 text-left">
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
@@ -194,16 +227,19 @@ export function SourceCard({
|
||||
{source.index}
|
||||
</span>
|
||||
)}
|
||||
<DialogTitle className="text-base leading-relaxed">
|
||||
<SheetTitle className="text-base leading-relaxed">
|
||||
{source.title}
|
||||
</DialogTitle>
|
||||
</SheetTitle>
|
||||
</div>
|
||||
{meta.length > 0 && (
|
||||
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||
<SheetDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`}>{item}</span>
|
||||
))}
|
||||
</DialogDescription>
|
||||
</SheetDescription>
|
||||
)}
|
||||
{meta.length === 0 && (
|
||||
<SheetDescription className="sr-only">参考文献详情</SheetDescription>
|
||||
)}
|
||||
</div>
|
||||
{source.url && !previewTarget && (
|
||||
@@ -219,33 +255,51 @@ export function SourceCard({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{content ? (
|
||||
<div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
||||
{isWikiSource && (
|
||||
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<BookOpenTextIcon className="size-4" />
|
||||
本次检索命中的 Wiki 文档内容
|
||||
</div>
|
||||
)}
|
||||
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
) : null}
|
||||
{source.url && !previewTarget && (
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||
{source.url}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
||||
{content ? (
|
||||
<>
|
||||
{isWikiSource && (
|
||||
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<BookOpenTextIcon className="size-4" />
|
||||
本次检索命中的 Wiki 文档内容
|
||||
</div>
|
||||
)}
|
||||
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
linkClassName={(href) =>
|
||||
href.startsWith("/weknora-source-preview?")
|
||||
? "font-medium text-red-600 decoration-red-300 hover:text-red-700 hover:decoration-red-600 dark:text-red-400"
|
||||
: undefined
|
||||
}
|
||||
onLinkClick={(event, href) => {
|
||||
const target = resolveWeKnoraSourceTarget({ url: href });
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
setLinkedTarget(target);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex min-h-64 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
|
||||
暂无可展示的引用内容
|
||||
</div>
|
||||
)}
|
||||
{source.url && !previewTarget && (
|
||||
<div className="text-muted-foreground mt-4 border-t pt-3 text-xs break-all">
|
||||
{source.url}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const PHASE_LABELS: Record<string, string> = {
|
||||
writing_sources: "上传知识资料",
|
||||
generating_wiki: "分析并生成分类 Wiki",
|
||||
vectorizing_wiki: "Wiki 向量化",
|
||||
syncing_assistant: "同步知识梳理总库",
|
||||
syncing_assistant: "同步检索索引",
|
||||
activating: "激活新版本",
|
||||
completed: "完成",
|
||||
};
|
||||
@@ -219,14 +219,14 @@ export function SkillKnowledgePanel({ adminMode = false }: { adminMode?: boolean
|
||||
{!runtime.isLoading && !knowledgeBases.isLoading && !knowledgeBases.error && !targets.length && (
|
||||
<p className="p-3 text-xs text-muted-foreground">
|
||||
{runtime.data?.provider !== "weknora"
|
||||
? "当前未配置普通知识库地址;技能需要先归纳到普通知识库,再导入助手知识库。"
|
||||
? "当前未配置普通知识库地址;请先配置知识服务地址,再归纳技能内容。"
|
||||
: "当前没有可写且已开启 Wiki 的普通知识库;请确认该库不是 FAQ/会话沉淀库,并已开启 Wiki。"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
|
||||
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及同步到“知识梳理”总库均在后台完成。
|
||||
第三步:提交后可离开本页。归纳、Wiki 生成、向量化及所需索引同步均在后台完成。
|
||||
</div>
|
||||
<Button className="w-full" disabled={actions.createJob.isPending || !selectedSkills.length || !selectedTargetKey} onClick={() => void submit()}>
|
||||
{actions.createJob.isPending ? <Loader2 className="animate-spin" /> : <Sparkles />}
|
||||
|
||||
@@ -226,7 +226,9 @@ export function SkillDetailDialog({
|
||||
style={{
|
||||
width: "min(48rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
height: "min(760px, calc(100dvh - 2rem))",
|
||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
||||
height: "min(760px, calc(100vh - 2rem))",
|
||||
maxHeight: "min(760px, calc(100vh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||
|
||||
@@ -149,7 +149,9 @@ export function SkillFilePreviewDialog({
|
||||
style={{
|
||||
width: "min(56rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
height: "min(720px, calc(100dvh - 2rem))",
|
||||
minHeight: "min(420px, calc(100vh - 2rem))",
|
||||
height: "min(720px, calc(100vh - 2rem))",
|
||||
maxHeight: "min(720px, calc(100vh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-3">
|
||||
|
||||
@@ -8,21 +8,31 @@ export interface TagColorDef {
|
||||
chip: string;
|
||||
/** A solid swatch used in the colour picker. */
|
||||
swatch: string;
|
||||
/** Real border hex — Tailwind `border-*-200` is often not generated, so `* { border-color: var(--border) }` greys every chip. */
|
||||
border: string;
|
||||
borderDark: string;
|
||||
}
|
||||
|
||||
export const TAG_COLORS: TagColorDef[] = [
|
||||
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
|
||||
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
|
||||
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
|
||||
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
|
||||
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
|
||||
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
|
||||
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
|
||||
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
|
||||
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
|
||||
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
|
||||
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400", border: "#d1d5db", borderDark: "#4b5563" },
|
||||
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500", border: "#fca5a5", borderDark: "#7f1d1d" },
|
||||
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500", border: "#fdba74", borderDark: "#7c2d12" },
|
||||
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500", border: "#fcd34d", borderDark: "#78350f" },
|
||||
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500", border: "#86efac", borderDark: "#14532d" },
|
||||
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500", border: "#5eead4", borderDark: "#134e4a" },
|
||||
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500", border: "#93c5fd", borderDark: "#1e3a8a" },
|
||||
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500", border: "#a5b4fc", borderDark: "#312e81" },
|
||||
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500", border: "#d8b4fe", borderDark: "#581c87" },
|
||||
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500", border: "#f9a8d4", borderDark: "#831843" },
|
||||
];
|
||||
|
||||
export function tagBorderStyle(color: TagColorDef) {
|
||||
return {
|
||||
"--lam-tag-border": color.border,
|
||||
"--lam-tag-border-dark": color.borderDark,
|
||||
} as Record<string, string>;
|
||||
}
|
||||
|
||||
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
|
||||
|
||||
export function resolveTagColor(name: string | undefined | null): TagColorDef {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as maplibregl from "maplibre-gl";
|
||||
import type { StyleSpecification } from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { AlertTriangle, MapPinned, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getMapTileAttribution, getMapTileUrl } from "@/core/utils/map-tile-config";
|
||||
|
||||
import type { MapSpec } from "./visualization-spec";
|
||||
import { VisualizationCard } from "./visualization-card";
|
||||
|
||||
function createStyle(): StyleSpecification {
|
||||
return {
|
||||
version: 8,
|
||||
sources: {
|
||||
"google-raster": {
|
||||
type: "raster",
|
||||
tiles: [getMapTileUrl()],
|
||||
tileSize: 256,
|
||||
attribution: getMapTileAttribution(),
|
||||
},
|
||||
},
|
||||
layers: [{ id: "google-raster", type: "raster", source: "google-raster" }],
|
||||
};
|
||||
}
|
||||
|
||||
function allCoordinates(spec: MapSpec): Array<[number, number]> {
|
||||
return [
|
||||
...spec.markers.map((marker) => marker.coordinate),
|
||||
...spec.lines.flatMap((line) => line.coordinates),
|
||||
...spec.polygons.flatMap((polygon) => polygon.coordinates),
|
||||
];
|
||||
}
|
||||
|
||||
function defaultCenter(spec: MapSpec): [number, number] {
|
||||
if (spec.center) return spec.center;
|
||||
const coordinates = allCoordinates(spec);
|
||||
if (coordinates.length === 0) return [104.1954, 35.8617];
|
||||
const total = coordinates.reduce(([lng, lat], coordinate) => [lng + coordinate[0], lat + coordinate[1]], [0, 0]);
|
||||
return [total[0] / coordinates.length, total[1] / coordinates.length];
|
||||
}
|
||||
|
||||
export default function MapRenderer({ spec }: { spec: MapSpec }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const [tileFailed, setTileFailed] = useState(false);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
const coordinates = useMemo(() => allCoordinates(spec), [spec]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
setTileFailed(false);
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container,
|
||||
style: createStyle(),
|
||||
center: defaultCenter(spec),
|
||||
zoom: spec.zoom ?? (coordinates.length > 1 ? 4 : 8),
|
||||
minZoom: 1,
|
||||
maxZoom: 20,
|
||||
attributionControl: { compact: true },
|
||||
});
|
||||
mapRef.current = map;
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||
|
||||
const markers: maplibregl.Marker[] = [];
|
||||
for (const marker of spec.markers) {
|
||||
const element = document.createElement("button");
|
||||
element.type = "button";
|
||||
element.className = "size-5 rounded-full border-2 border-white bg-primary shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50";
|
||||
element.title = marker.name;
|
||||
element.setAttribute("aria-label", marker.name);
|
||||
const popup = new maplibregl.Popup({ offset: 14 }).setHTML(
|
||||
`<div style="max-width:240px"><strong>${escapeHtml(marker.name)}</strong>${marker.description ? `<p style="margin:6px 0 0">${escapeHtml(marker.description)}</p>` : ""}</div>`,
|
||||
);
|
||||
markers.push(new maplibregl.Marker({ element }).setLngLat(marker.coordinate).setPopup(popup).addTo(map));
|
||||
}
|
||||
|
||||
let baseTileLoaded = false;
|
||||
const onSourceData = (event: maplibregl.MapSourceDataEvent) => {
|
||||
if (event.sourceId !== "google-raster" || event.sourceDataType !== "content") return;
|
||||
baseTileLoaded = true;
|
||||
setTileFailed(false);
|
||||
};
|
||||
const onError = (event: maplibregl.ErrorEvent) => {
|
||||
const message = event.error?.message ?? "";
|
||||
if (!baseTileLoaded && /tile|fetch|network|request|http/i.test(message)) setTileFailed(true);
|
||||
};
|
||||
map.on("sourcedata", onSourceData);
|
||||
map.on("error", onError);
|
||||
|
||||
map.on("load", () => {
|
||||
if (spec.lines.length > 0) {
|
||||
map.addSource("visual-lines", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: spec.lines.map((line) => ({
|
||||
type: "Feature",
|
||||
properties: { name: line.name ?? "" },
|
||||
geometry: { type: "LineString", coordinates: line.coordinates },
|
||||
})),
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: "visual-lines",
|
||||
type: "line",
|
||||
source: "visual-lines",
|
||||
paint: { "line-color": "#2563eb", "line-width": 3, "line-opacity": 0.85 },
|
||||
});
|
||||
}
|
||||
|
||||
if (spec.polygons.length > 0) {
|
||||
map.addSource("visual-polygons", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: spec.polygons.map((polygon) => ({
|
||||
type: "Feature",
|
||||
properties: { name: polygon.name ?? "" },
|
||||
geometry: { type: "Polygon", coordinates: [[...polygon.coordinates, polygon.coordinates[0]]] },
|
||||
})),
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: "visual-polygons-fill",
|
||||
type: "fill",
|
||||
source: "visual-polygons",
|
||||
paint: { "fill-color": "#2563eb", "fill-opacity": 0.18 },
|
||||
});
|
||||
map.addLayer({
|
||||
id: "visual-polygons-border",
|
||||
type: "line",
|
||||
source: "visual-polygons",
|
||||
paint: { "line-color": "#2563eb", "line-width": 2 },
|
||||
});
|
||||
}
|
||||
|
||||
if (!spec.center && coordinates.length > 1) {
|
||||
const bounds = coordinates.reduce(
|
||||
(current, coordinate) => current.extend(coordinate),
|
||||
new maplibregl.LngLatBounds(coordinates[0], coordinates[0]),
|
||||
);
|
||||
map.fitBounds(bounds, { padding: 52, maxZoom: 12, duration: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
map.off("sourcedata", onSourceData);
|
||||
map.off("error", onError);
|
||||
markers.forEach((marker) => marker.remove());
|
||||
mapRef.current = null;
|
||||
map.remove();
|
||||
};
|
||||
}, [coordinates, reloadToken, spec]);
|
||||
|
||||
return (
|
||||
<VisualizationCard>
|
||||
<div className="flex min-h-12 items-center gap-2 border-b px-3 py-2">
|
||||
<MapPinned aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
||||
<span className="truncate text-sm font-medium">{spec.title}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{spec.markers.length} 个地点</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div ref={containerRef} className="h-[420px] w-full" role="img" aria-label={`${spec.title},包含 ${spec.markers.length} 个地点`} />
|
||||
{tileFailed ? (
|
||||
<div className="absolute inset-x-3 top-3 z-10 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50/95 p-3 text-xs text-amber-900 shadow-sm" role="status">
|
||||
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||
<span className="flex-1">地图底图当前不可访问;请检查网络。地点和线路数据不会丢失。</span>
|
||||
<Button
|
||||
aria-label="重新加载地图"
|
||||
className="shrink-0 text-amber-950 hover:bg-amber-100"
|
||||
onClick={() => {
|
||||
setTileFailed(false);
|
||||
setReloadToken((value) => value + 1);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{spec.markers.length > 0 ? (
|
||||
<details className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">查看地点列表</summary>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{spec.markers.map((marker, index) => (
|
||||
<li key={`${marker.name}-${index}`}>
|
||||
<span className="font-medium text-foreground">{marker.name}</span>
|
||||
<span>({marker.coordinate[0]},{marker.coordinate[1]})</span>
|
||||
{marker.description ? <span>:{marker.description}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
</VisualizationCard>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>'"]/g, (character) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"'": "'",
|
||||
'"': """,
|
||||
})[character] ?? character);
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Handle,
|
||||
Panel,
|
||||
Position,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import {
|
||||
Download,
|
||||
FileJson,
|
||||
ImageDown,
|
||||
LocateFixed,
|
||||
Maximize2,
|
||||
Minus,
|
||||
Plus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { renderSvgElementToPngBlob, triggerBlobDownload } from "@/core/utils/mermaid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { VisualizationCard } from "./visualization-card";
|
||||
import type { MindmapNodeKind, MindmapSpec } from "./visualization-spec";
|
||||
|
||||
const NODE_WIDTH = 184;
|
||||
const NODE_HEIGHT = 68;
|
||||
const LAYER_GAP = 244;
|
||||
const ROW_GAP = 88;
|
||||
const PREVIEW_DEPTH = 2;
|
||||
|
||||
const KIND_META: Record<MindmapNodeKind, { label: string; className: string }> = {
|
||||
topic: { label: "主题", className: "border-primary/50 bg-primary/10" },
|
||||
dimension: { label: "维度", className: "border-sky-500/45 bg-sky-500/10" },
|
||||
finding: { label: "发现", className: "border-violet-500/45 bg-violet-500/10" },
|
||||
evidence: { label: "依据", className: "border-emerald-500/45 bg-emerald-500/10" },
|
||||
risk: { label: "风险", className: "border-amber-500/50 bg-amber-500/10" },
|
||||
action: { label: "建议", className: "border-rose-500/45 bg-rose-500/10" },
|
||||
};
|
||||
|
||||
type MindmapNode = MindmapSpec["nodes"][number];
|
||||
|
||||
type LayoutNode = MindmapNode & {
|
||||
depth: number;
|
||||
x: number;
|
||||
y: number;
|
||||
hiddenCount: number;
|
||||
hasChildren: boolean;
|
||||
collapsed: boolean;
|
||||
};
|
||||
|
||||
type MindmapLayout = {
|
||||
nodes: LayoutNode[];
|
||||
edges: Array<{ id: string; from: string; to: string }>;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function childrenByParent(spec: MindmapSpec) {
|
||||
const children = new Map<string, MindmapNode[]>();
|
||||
for (const node of spec.nodes) {
|
||||
if (node.parentId === null) continue;
|
||||
const list = children.get(node.parentId) ?? [];
|
||||
list.push(node);
|
||||
children.set(node.parentId, list);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function getRoot(spec: MindmapSpec): MindmapNode {
|
||||
return spec.nodes.find((node) => node.parentId === null)!;
|
||||
}
|
||||
|
||||
function descendantsCount(id: string, children: Map<string, MindmapNode[]>): number {
|
||||
let count = 0;
|
||||
for (const child of children.get(id) ?? []) {
|
||||
count += 1 + descendantsCount(child.id, children);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* A rooted analysis tree uses a left-to-right layout in messages. It reads more
|
||||
* naturally than a radial graph inside a narrow chat column and stays stable
|
||||
* when the same persisted message is replayed.
|
||||
*/
|
||||
function layoutMindmap(
|
||||
spec: MindmapSpec,
|
||||
maxDepth?: number,
|
||||
collapsedIds: ReadonlySet<string> = new Set(),
|
||||
): MindmapLayout {
|
||||
const children = childrenByParent(spec);
|
||||
const root = getRoot(spec);
|
||||
const visible = new Map<string, { node: MindmapNode; depth: number }>();
|
||||
|
||||
const collect = (node: MindmapNode, depth: number) => {
|
||||
visible.set(node.id, { node, depth });
|
||||
if ((maxDepth !== undefined && depth >= maxDepth) || collapsedIds.has(node.id)) return;
|
||||
for (const child of children.get(node.id) ?? []) collect(child, depth + 1);
|
||||
};
|
||||
collect(root, 0);
|
||||
|
||||
const unitHeight = (id: string): number => {
|
||||
const childIds = (children.get(id) ?? []).filter((child) => visible.has(child.id));
|
||||
if (childIds.length === 0) return 1;
|
||||
return childIds.reduce((sum, child) => sum + unitHeight(child.id), 0);
|
||||
};
|
||||
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const assign = (id: string, depth: number, top: number): number => {
|
||||
const ownUnits = unitHeight(id);
|
||||
positions.set(id, {
|
||||
x: depth * LAYER_GAP,
|
||||
y: top + ((ownUnits - 1) * ROW_GAP) / 2,
|
||||
});
|
||||
let nextTop = top;
|
||||
for (const child of children.get(id) ?? []) {
|
||||
if (!visible.has(child.id)) continue;
|
||||
nextTop = assign(child.id, depth + 1, nextTop) + ROW_GAP;
|
||||
}
|
||||
return top + (ownUnits - 1) * ROW_GAP;
|
||||
};
|
||||
assign(root.id, 0, 0);
|
||||
|
||||
const nodes = [...visible.values()].map(({ node, depth }) => ({
|
||||
...node,
|
||||
depth,
|
||||
x: positions.get(node.id)?.x ?? 0,
|
||||
y: positions.get(node.id)?.y ?? 0,
|
||||
hiddenCount:
|
||||
(maxDepth !== undefined && depth === maxDepth) || collapsedIds.has(node.id)
|
||||
? descendantsCount(node.id, children)
|
||||
: 0,
|
||||
hasChildren: (children.get(node.id) ?? []).length > 0,
|
||||
collapsed: collapsedIds.has(node.id),
|
||||
}));
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
const edges = nodes
|
||||
.filter((node) => node.parentId && nodeIds.has(node.parentId))
|
||||
.map((node) => ({ id: `${node.parentId}->${node.id}`, from: node.parentId!, to: node.id }));
|
||||
const maxX = Math.max(...nodes.map((node) => node.x), 0);
|
||||
const maxY = Math.max(...nodes.map((node) => node.y), 0);
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
width: maxX + NODE_WIDTH + 64,
|
||||
height: maxY + NODE_HEIGHT + 64,
|
||||
};
|
||||
}
|
||||
|
||||
type FlowNodeData = {
|
||||
node: LayoutNode;
|
||||
selected: boolean;
|
||||
onToggle?: (id: string) => void;
|
||||
};
|
||||
|
||||
function MindmapFlowNode({ data }: NodeProps) {
|
||||
const { node, selected, onToggle } = data as FlowNodeData;
|
||||
const meta = KIND_META[node.kind];
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[184px] rounded-xl border bg-background px-3 py-2 shadow-sm transition-shadow",
|
||||
"hover:shadow-md focus-within:ring-2 focus-within:ring-primary/50",
|
||||
meta.className,
|
||||
selected && "ring-2 ring-primary ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
title={node.label}
|
||||
>
|
||||
{node.parentId ? <Handle type="target" position={Position.Left} className="!size-1.5 !border-0 !bg-border" /> : null}
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[11px] leading-none text-muted-foreground">
|
||||
<span className="font-medium">{meta.label}</span>
|
||||
{node.hiddenCount > 0 ? (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
+{node.hiddenCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{node.label}</div>
|
||||
{node.hasChildren ? (
|
||||
<>
|
||||
<Handle type="source" position={Position.Right} className="!size-6 !border-0 !bg-transparent" />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -right-3 top-1/2 z-20 flex size-6 -translate-y-1/2 items-center justify-center rounded-full border border-slate-400 bg-background text-slate-600 shadow-sm transition-colors hover:border-primary hover:bg-primary hover:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={`${node.collapsed ? "展开" : "收起"}${node.label}`}
|
||||
aria-expanded={!node.collapsed}
|
||||
title={node.collapsed ? "展开子节点" : "收起子节点"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggle?.(node.id);
|
||||
}}
|
||||
>
|
||||
{node.collapsed ? <Plus aria-hidden="true" className="size-3.5" /> : <Minus aria-hidden="true" className="size-3.5" />}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NODE_TYPES = { mindmap: MindmapFlowNode };
|
||||
|
||||
function MindmapCanvasControls({
|
||||
compact,
|
||||
onClose,
|
||||
}: {
|
||||
compact: boolean;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const rf = useReactFlow();
|
||||
const buttonClassName = compact ? "size-7" : "size-8";
|
||||
const padding = compact ? 0.16 : 0.12;
|
||||
|
||||
return (
|
||||
<Panel position="top-right" className="flex items-center gap-1 rounded-lg border bg-background/95 p-1 shadow-sm backdrop-blur">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title="放大"
|
||||
aria-label="放大思维导图"
|
||||
onClick={() => void rf.zoomIn({ duration: 150 })}
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title="缩小"
|
||||
aria-label="缩小思维导图"
|
||||
onClick={() => void rf.zoomOut({ duration: 150 })}
|
||||
>
|
||||
<Minus aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title="重置位置与缩放"
|
||||
aria-label="重置思维导图位置与缩放"
|
||||
onClick={() => void rf.fitView({ duration: 220, padding })}
|
||||
>
|
||||
<LocateFixed aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
{onClose ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="mx-0.5 h-4 w-px bg-border" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title="关闭"
|
||||
aria-label="关闭思维导图"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function overviewNodeColor(kind: MindmapNodeKind): string {
|
||||
return svgColors(kind).fill;
|
||||
}
|
||||
|
||||
function MindmapOverview({ layout }: { layout: MindmapLayout }) {
|
||||
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]));
|
||||
|
||||
return (
|
||||
<Panel position="bottom-right" className="overflow-hidden rounded-lg border bg-background/95 p-1 shadow-md backdrop-blur">
|
||||
<div className="mb-1 px-1.5 pt-0.5 text-[10px] font-medium text-muted-foreground">全图预览</div>
|
||||
<svg
|
||||
className="block h-28 w-44 rounded bg-muted/30"
|
||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
role="img"
|
||||
aria-label="思维导图全图预览"
|
||||
>
|
||||
{layout.edges.map((edge) => {
|
||||
const from = nodesById.get(edge.from);
|
||||
const to = nodesById.get(edge.to);
|
||||
if (!from || !to) return null;
|
||||
return (
|
||||
<path
|
||||
key={edge.id}
|
||||
d={`M ${from.x + NODE_WIDTH} ${from.y + NODE_HEIGHT / 2} L ${to.x} ${to.y + NODE_HEIGHT / 2}`}
|
||||
fill="none"
|
||||
stroke="#64748b"
|
||||
strokeWidth="4"
|
||||
strokeOpacity="0.8"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{layout.nodes.map((node) => (
|
||||
<rect
|
||||
key={node.id}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
rx="10"
|
||||
fill={overviewNodeColor(node.kind)}
|
||||
stroke="#94a3b8"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function MindmapCanvas({
|
||||
spec,
|
||||
preview,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
spec: MindmapSpec;
|
||||
preview: boolean;
|
||||
selectedId?: string | null;
|
||||
onSelect?: (id: string) => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const rf = useReactFlow();
|
||||
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(() => new Set());
|
||||
const toggleNode = useCallback((id: string) => {
|
||||
setCollapsedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const layout = useMemo(
|
||||
() => layoutMindmap(spec, preview ? PREVIEW_DEPTH : undefined, collapsedIds),
|
||||
[collapsedIds, preview, spec],
|
||||
);
|
||||
const nodes = useMemo<Node[]>(
|
||||
() =>
|
||||
layout.nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: "mindmap",
|
||||
position: { x: node.x, y: node.y },
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
data: { node, selected: node.id === selectedId, onToggle: toggleNode } satisfies FlowNodeData,
|
||||
})),
|
||||
[layout.nodes, selectedId, toggleNode],
|
||||
);
|
||||
const edges = useMemo<Edge[]>(
|
||||
() =>
|
||||
layout.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
type: "smoothstep",
|
||||
style: { stroke: "#64748b", strokeWidth: 2, strokeOpacity: 0.8 },
|
||||
})),
|
||||
[layout.edges],
|
||||
);
|
||||
const handleNodeClick = useCallback((id: string) => {
|
||||
const node = layout.nodes.find((item) => item.id === id);
|
||||
if (node?.hasChildren) {
|
||||
toggleNode(id);
|
||||
return;
|
||||
}
|
||||
onSelect?.(id);
|
||||
}, [layout.nodes, onSelect, toggleNode]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void rf.fitView({ padding: preview ? 0.16 : 0.12, duration: 180 });
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [collapsedIds, preview, rf, spec]);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={NODE_TYPES}
|
||||
fitView
|
||||
fitViewOptions={{ padding: preview ? 0.16 : 0.12 }}
|
||||
minZoom={0.12}
|
||||
maxZoom={2}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={Boolean(onSelect)}
|
||||
panOnDrag
|
||||
panOnScroll={false}
|
||||
zoomOnScroll
|
||||
zoomOnDoubleClick
|
||||
zoomOnPinch
|
||||
preventScrolling
|
||||
onNodeClick={(_, node) => handleNodeClick(node.id)}
|
||||
aria-label={`${spec.title}思维导图`}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="var(--border)" />
|
||||
<MindmapCanvasControls compact={preview} onClose={onClose} />
|
||||
{!preview ? <MindmapOverview layout={layout} /> : null}
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
function svgEscape(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!);
|
||||
}
|
||||
|
||||
function splitSvgLabel(label: string): string[] {
|
||||
const chunks = label.match(/.{1,16}/gu) ?? [label];
|
||||
return chunks.slice(0, 2);
|
||||
}
|
||||
|
||||
function buildMindmapSvg(spec: MindmapSpec): string {
|
||||
const layout = layoutMindmap(spec);
|
||||
const offset = 32;
|
||||
const lines = layout.edges
|
||||
.map((edge) => {
|
||||
const from = layout.nodes.find((node) => node.id === edge.from)!;
|
||||
const to = layout.nodes.find((node) => node.id === edge.to)!;
|
||||
const x1 = from.x + NODE_WIDTH + offset;
|
||||
const y1 = from.y + NODE_HEIGHT / 2 + offset;
|
||||
const x2 = to.x + offset;
|
||||
const y2 = to.y + NODE_HEIGHT / 2 + offset;
|
||||
const control = Math.max(42, (x2 - x1) / 2);
|
||||
return `<path d="M ${x1} ${y1} C ${x1 + control} ${y1}, ${x2 - control} ${y2}, ${x2} ${y2}" fill="none" stroke="#94a3b8" stroke-width="1.5"/>`;
|
||||
})
|
||||
.join("");
|
||||
const nodes = layout.nodes
|
||||
.map((node) => {
|
||||
const meta = KIND_META[node.kind];
|
||||
const { fill, stroke } = svgColors(node.kind);
|
||||
const lines = splitSvgLabel(node.label)
|
||||
.map((line, index) => `<tspan x="${node.x + offset + 14}" dy="${index === 0 ? 0 : 18}">${svgEscape(line)}</tspan>`)
|
||||
.join("");
|
||||
return [
|
||||
`<g>`,
|
||||
`<rect x="${node.x + offset}" y="${node.y + offset}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}" rx="12" fill="${fill}" stroke="${stroke}" stroke-width="1.5"/>`,
|
||||
`<text x="${node.x + offset + 14}" y="${node.y + offset + 18}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="10" fill="#64748b">${meta.label}</text>`,
|
||||
`<text x="${node.x + offset + 14}" y="${node.y + offset + 42}" font-family="Microsoft YaHei, Arial, sans-serif" font-size="13" font-weight="600" fill="#0f172a">${lines}</text>`,
|
||||
`</g>`,
|
||||
].join("");
|
||||
})
|
||||
.join("");
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${layout.width + offset}" height="${layout.height + offset}" viewBox="0 0 ${layout.width + offset} ${layout.height + offset}" role="img" aria-label="${svgEscape(spec.title)}"><rect width="100%" height="100%" fill="#ffffff"/><text x="32" y="26" font-family="Microsoft YaHei, Arial, sans-serif" font-size="16" font-weight="700" fill="#0f172a">${svgEscape(spec.title)}</text>${lines}${nodes}</svg>`;
|
||||
}
|
||||
|
||||
function svgColors(kind: MindmapNodeKind): { fill: string; stroke: string } {
|
||||
switch (kind) {
|
||||
case "topic": return { fill: "#eff6ff", stroke: "#60a5fa" };
|
||||
case "dimension": return { fill: "#f0f9ff", stroke: "#38bdf8" };
|
||||
case "evidence": return { fill: "#ecfdf5", stroke: "#34d399" };
|
||||
case "risk": return { fill: "#fffbeb", stroke: "#fbbf24" };
|
||||
case "action": return { fill: "#fff1f2", stroke: "#fb7185" };
|
||||
default: return { fill: "#f5f3ff", stroke: "#a78bfa" };
|
||||
}
|
||||
}
|
||||
|
||||
function safeDownloadName(title: string, extension: string): string {
|
||||
const clean = title.replace(/[\\/:*?"<>|]/g, "-").trim().slice(0, 80) || "分析思维导图";
|
||||
return `${clean}.${extension}`;
|
||||
}
|
||||
|
||||
function MindmapDownloads({ spec }: { spec: MindmapSpec }) {
|
||||
const [downloading, setDownloading] = useState<"png" | "svg" | "json" | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const download = useCallback(async (format: "png" | "svg" | "json") => {
|
||||
setDownloading(format);
|
||||
try {
|
||||
if (format === "json") {
|
||||
triggerBlobDownload(
|
||||
new Blob([JSON.stringify(spec, null, 2)], { type: "application/json;charset=utf-8" }),
|
||||
safeDownloadName(spec.title, "mindmap.json"),
|
||||
);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const svgMarkup = buildMindmapSvg(spec);
|
||||
if (format === "svg") {
|
||||
triggerBlobDownload(
|
||||
new Blob([svgMarkup], { type: "image/svg+xml;charset=utf-8" }),
|
||||
safeDownloadName(spec.title, "svg"),
|
||||
);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "position:fixed;left:-10000px;top:-10000px;pointer-events:none;opacity:0";
|
||||
host.innerHTML = svgMarkup;
|
||||
document.body.appendChild(host);
|
||||
try {
|
||||
const svg = host.querySelector("svg");
|
||||
if (!(svg instanceof SVGSVGElement)) throw new Error("无法生成思维导图图片");
|
||||
const blob = await renderSvgElementToPngBlob(svg);
|
||||
triggerBlobDownload(blob, safeDownloadName(spec.title, "png"));
|
||||
setOpen(false);
|
||||
} finally {
|
||||
host.remove();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to download mindmap", error);
|
||||
toast.error(error instanceof Error ? error.message : "下载思维导图失败");
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
}, [spec]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-9"
|
||||
title="下载思维导图"
|
||||
aria-label="下载思维导图"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Download aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-sm p-5">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle>下载思维导图</DialogTitle>
|
||||
<DialogDescription>选择需要导出的文件格式。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
|
||||
onClick={() => void download("png")}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
<ImageDown aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>
|
||||
<span className="block text-sm font-medium">PNG 图片</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">
|
||||
{downloading === "png" ? "正在生成…" : "便于在文档或图片中使用"}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
|
||||
onClick={() => void download("svg")}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
<Download aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>
|
||||
<span className="block text-sm font-medium">SVG 矢量图</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">适合无损缩放和二次编辑</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-auto min-h-11 justify-start gap-3 px-3 py-2.5 text-left"
|
||||
onClick={() => void download("json")}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
<FileJson aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>
|
||||
<span className="block text-sm font-medium">JSON 数据</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">保留完整节点结构</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MindmapDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
spec,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
spec: MindmapSpec;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex h-[calc(100dvh-2rem)] !w-[calc(100vw-2rem)] !max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden rounded-xl p-0 sm:!max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
<DialogTitle className="sr-only">{spec.title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
思维导图画布。支持鼠标滚轮缩放、拖动画布,并可使用右上角按钮调整视图。
|
||||
</DialogDescription>
|
||||
<div className="relative min-h-0 flex-1 bg-muted/20">
|
||||
<div className="absolute inset-0">
|
||||
<ReactFlowProvider>
|
||||
<MindmapCanvas spec={spec} preview={false} onClose={() => onOpenChange(false)} />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MindmapRenderer({ spec }: { spec: MindmapSpec }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const root = getRoot(spec);
|
||||
const ariaSummary = spec.nodes.map((node) => `${KIND_META[node.kind].label}:${node.label}`).join(";");
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisualizationCard className="my-4" >
|
||||
<section aria-label={`${spec.title}思维导图`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-sm font-semibold">{spec.title}</h2>
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary">思维导图</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{spec.summary || `围绕“${root.label}”整理的 ${spec.nodes.length} 个分析节点。`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MindmapDownloads spec={spec} />
|
||||
<Button type="button" size="sm" className="h-8 gap-1.5" onClick={() => setOpen(true)}>
|
||||
<Maximize2 aria-hidden="true" className="size-3.5" />
|
||||
展开查看
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="sr-only">{ariaSummary}</p>
|
||||
<div className="h-[20rem] bg-muted/15">
|
||||
<ReactFlowProvider>
|
||||
<MindmapCanvas
|
||||
spec={spec}
|
||||
preview
|
||||
onSelect={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 border-t px-4 py-2 text-xs text-muted-foreground">
|
||||
<span>预览显示前 {PREVIEW_DEPTH + 1} 层;完整导图可缩放查看。</span>
|
||||
<span>{spec.nodes.length} 个节点</span>
|
||||
</div>
|
||||
</section>
|
||||
</VisualizationCard>
|
||||
<MindmapDialog open={open} onOpenChange={setOpen} spec={spec} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Component, lazy, Suspense, useMemo, type ErrorInfo, type ReactNode } from "react";
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
parseVisualizationSpec,
|
||||
type MapSpec,
|
||||
type MindmapSpec,
|
||||
type VisualizationLanguage,
|
||||
} from "./visualization-spec";
|
||||
|
||||
const MapRenderer = lazy(() => import("./map-renderer"));
|
||||
const MindmapRenderer = lazy(() => import("./mindmap-renderer"));
|
||||
|
||||
type VisualizationBlockProps = {
|
||||
language: VisualizationLanguage;
|
||||
source: string;
|
||||
complete: boolean;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function LoadingCard({ label = "正在生成可视化内容…" }: { label?: string }) {
|
||||
return (
|
||||
<div
|
||||
className="my-4 flex min-h-32 w-full max-w-none items-center justify-center rounded-xl border bg-muted/20 text-muted-foreground"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 aria-hidden="true" className="mr-2 size-4 animate-spin" />
|
||||
<span className="text-sm">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorCard({
|
||||
message,
|
||||
source,
|
||||
title = "可视化内容暂时无法显示",
|
||||
}: {
|
||||
message: string;
|
||||
source?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="my-4 w-full max-w-none rounded-xl border border-destructive/30 bg-destructive/5 p-4"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="mt-1 text-xs opacity-80">{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
{source ? (
|
||||
<details className="mt-3 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">查看原始数据</summary>
|
||||
<pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-3">
|
||||
{source}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class VisualizationErrorBoundary extends Component<
|
||||
{ children: ReactNode; source: string },
|
||||
{ error: string | null }
|
||||
> {
|
||||
state = { error: null as string | null };
|
||||
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
return { error: error instanceof Error ? error.message : "可视化渲染失败" };
|
||||
}
|
||||
|
||||
componentDidCatch(error: unknown, info: ErrorInfo) {
|
||||
console.error("Visualization renderer failed", error, info.componentStack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <ErrorCard message={this.state.error} source={this.props.source} />;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function VisualizationBlock({
|
||||
language,
|
||||
source,
|
||||
complete,
|
||||
isLoading,
|
||||
}: VisualizationBlockProps) {
|
||||
const result = useMemo(
|
||||
() => parseVisualizationSpec(language, source),
|
||||
[language, source],
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
if (isLoading && !complete) {
|
||||
return <LoadingCard label={language === "mindmap-json" ? "正在整理思维导图…" : undefined} />;
|
||||
}
|
||||
return (
|
||||
<ErrorCard
|
||||
title={language === "mindmap-json" ? "思维导图暂时无法显示" : "地图内容暂时无法显示"}
|
||||
message={result.error}
|
||||
source={source}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VisualizationErrorBoundary key={`${language}-${source.length}`} source={source}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<LoadingCard
|
||||
label={language === "mindmap-json" ? "正在加载思维导图组件…" : "正在加载本地地图组件…"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{language === "mindmap-json" ? (
|
||||
<MindmapRenderer spec={result.spec as MindmapSpec} />
|
||||
) : (
|
||||
<MapRenderer spec={result.spec as MapSpec} />
|
||||
)}
|
||||
</Suspense>
|
||||
</VisualizationErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function VisualizationCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"my-4 w-full max-w-none overflow-hidden rounded-xl border bg-background shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
// @ts-expect-error Node's type-stripping test runner requires the explicit .ts suffix.
|
||||
import { parseVisualizationSpec, splitVisualizationContent } from "./visualization-spec.ts";
|
||||
|
||||
test("ordinary markdown remains one unchanged segment", () => {
|
||||
const content = "普通回答\n\n```ts\nconst value = 1;\n```";
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("splits a dedicated map block while keeping surrounding markdown", () => {
|
||||
const content = [
|
||||
"下面是地图:\n",
|
||||
"```map-json",
|
||||
'{"title":"地点","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
|
||||
"```",
|
||||
"\n地图结束。",
|
||||
].join("\n");
|
||||
const segments = splitVisualizationContent(content);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.equal(segments[0]?.type, "markdown");
|
||||
assert.equal(segments[1]?.type, "visualization");
|
||||
assert.equal(segments[2]?.type, "markdown");
|
||||
});
|
||||
|
||||
test("accepts the map contract", () => {
|
||||
const map = parseVisualizationSpec(
|
||||
"map-json",
|
||||
JSON.stringify({ markers: [{ name: "北京", coordinate: [116.4, 39.9] }] }),
|
||||
);
|
||||
assert.equal(map.ok, true);
|
||||
});
|
||||
|
||||
test("accepts a dedicated mindmap contract", () => {
|
||||
const result = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
title: "形势分析",
|
||||
rootId: "root",
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "形势分析", kind: "topic" },
|
||||
{ id: "risk", parentId: "root", label: "主要风险", kind: "risk" },
|
||||
{ id: "action", parentId: "root", label: "处置建议", kind: "action" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.ok && result.spec.type, "mindmap");
|
||||
});
|
||||
|
||||
test("accepts four meaningful children under a non-root analysis node", () => {
|
||||
const result = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
title: "人物分析",
|
||||
rootId: "root",
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "人物分析", kind: "topic" },
|
||||
{ id: "basic", parentId: "root", label: "基本信息", kind: "dimension" },
|
||||
{ id: "identity", parentId: "basic", label: "身份", kind: "finding" },
|
||||
{ id: "experience", parentId: "basic", label: "经历", kind: "finding" },
|
||||
{ id: "relations", parentId: "basic", label: "关系", kind: "finding" },
|
||||
{ id: "events", parentId: "basic", label: "事件", kind: "finding" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(
|
||||
result.ok && result.spec.type === "mindmap" && result.spec.nodes.length,
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
test("renders a completed legacy mindmap fence as a mindmap", () => {
|
||||
const content = [
|
||||
"```mindmap",
|
||||
'{"rootId":"root","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
|
||||
"```",
|
||||
].join("\n");
|
||||
const visual = splitVisualizationContent(content).find((segment) => segment.type === "visualization");
|
||||
assert.ok(visual && visual.type === "visualization");
|
||||
assert.equal(visual.language, "mindmap-json");
|
||||
assert.equal(visual.complete, true);
|
||||
});
|
||||
|
||||
test("rejects mindmaps with a cycle or missing parent", () => {
|
||||
const cycle = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
{ id: "a", parentId: "b", label: "A" },
|
||||
{ id: "b", parentId: "a", label: "B" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(cycle.ok, false);
|
||||
|
||||
const missingParent = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
{ id: "a", parentId: "missing", label: "A" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(missingParent.ok, false);
|
||||
});
|
||||
|
||||
test("rejects dense mindmaps before they can overload the canvas", () => {
|
||||
const tooManyChildren = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
{ id: "a", parentId: "root", label: "维度 A" },
|
||||
{ id: "b", parentId: "root", label: "维度 B" },
|
||||
{ id: "c", parentId: "root", label: "维度 C" },
|
||||
{ id: "d", parentId: "root", label: "维度 D" },
|
||||
{ id: "e", parentId: "root", label: "维度 E" },
|
||||
{ id: "f", parentId: "root", label: "维度 F" },
|
||||
{ id: "g", parentId: "root", label: "维度 G" },
|
||||
{ id: "h", parentId: "root", label: "维度 H" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(tooManyChildren.ok, false);
|
||||
|
||||
const tooManyBranchChildren = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
{ id: "basic", parentId: "root", label: "基本信息" },
|
||||
...Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `detail-${index}`,
|
||||
parentId: "basic",
|
||||
label: `要点 ${index + 1}`,
|
||||
})),
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(tooManyBranchChildren.ok, false);
|
||||
assert.equal(
|
||||
tooManyBranchChildren.ok ? "" : tooManyBranchChildren.error,
|
||||
"节点“基本信息”最多保留 4 个子节点,当前有 5 个;请合并同类信息",
|
||||
);
|
||||
|
||||
const tooManyNodes = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
...Array.from({ length: 36 }, (_, index) => ({
|
||||
id: `node-${index}`,
|
||||
parentId: "root",
|
||||
label: `节点 ${index + 1}`,
|
||||
})),
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(tooManyNodes.ok, false);
|
||||
assert.equal(
|
||||
tooManyNodes.ok ? "" : tooManyNodes.error,
|
||||
"思维导图节点过多(37 个);请将同类信息合并后控制在 36 个以内",
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the four-layer depth guard after branch tolerance", () => {
|
||||
const tooDeep = parseVisualizationSpec(
|
||||
"mindmap-json",
|
||||
JSON.stringify({
|
||||
nodes: [
|
||||
{ id: "root", parentId: null, label: "主题" },
|
||||
{ id: "level-1", parentId: "root", label: "一级" },
|
||||
{ id: "level-2", parentId: "level-1", label: "二级" },
|
||||
{ id: "level-3", parentId: "level-2", label: "三级" },
|
||||
{ id: "level-4", parentId: "level-3", label: "四级" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(tooDeep.ok, false);
|
||||
assert.equal(
|
||||
tooDeep.ok ? "" : tooDeep.error,
|
||||
"思维导图最多支持 4 层层级",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders a valid map contract from a generic json fence", () => {
|
||||
const content = [
|
||||
"下面是地图:",
|
||||
"```json",
|
||||
'{"title":"城市分布","markers":[{"name":"北京","coordinate":[116.4,39.9]}]}',
|
||||
"```",
|
||||
].join("\n");
|
||||
const segments = splitVisualizationContent(content);
|
||||
const visual = segments.find((segment) => segment.type === "visualization");
|
||||
assert.ok(visual && visual.type === "visualization");
|
||||
assert.equal(visual.language, "map-json");
|
||||
});
|
||||
|
||||
test("keeps non-map json fences on the ordinary Markdown path", () => {
|
||||
const content = '```json\n{"name":"普通数据","items":[1,2,3]}\n```';
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("keeps generic mindmap-shaped JSON on the ordinary Markdown path", () => {
|
||||
const content = [
|
||||
"```json",
|
||||
'{"type":"mindmap","nodes":[{"id":"root","parentId":null,"label":"主题"}]}',
|
||||
"```",
|
||||
].join("\n");
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("keeps unfinished generic json fences on the ordinary Markdown path", () => {
|
||||
const content = '```json\n{"markers":[';
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("rejects invalid map coordinates", () => {
|
||||
const result = parseVisualizationSpec(
|
||||
"map-json",
|
||||
JSON.stringify({ markers: [{ name: "错误地点", coordinate: [200, 100] }] }),
|
||||
);
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
|
||||
test("flow and chart fences stay on the ordinary Markdown path", () => {
|
||||
const content = "```flow-json\n{}\n```\n```chart-json\n{}\n```";
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
|
||||
test("keeps an unfinished map fence for streaming fallback", () => {
|
||||
const segments = splitVisualizationContent("说明\n```map-json\n{\"title\":\"地点\"");
|
||||
const visual = segments.find((segment) => segment.type === "visualization");
|
||||
assert.ok(visual && visual.type === "visualization");
|
||||
assert.equal(visual.complete, false);
|
||||
});
|
||||
|
||||
test("shows unfinished dedicated mindmap JSON before mounting the renderer", () => {
|
||||
const content = "说明\n```mindmap-json\n{\"title\":\"分析导图\"";
|
||||
assert.deepEqual(splitVisualizationContent(content), [{ type: "markdown", content }]);
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const VISUALIZATION_LANGUAGES = ["map-json", "mindmap-json"] as const;
|
||||
|
||||
export type VisualizationLanguage = (typeof VISUALIZATION_LANGUAGES)[number];
|
||||
|
||||
const safeText = z.string().trim().min(1).max(500);
|
||||
const optionalText = z.string().trim().max(2_000).optional();
|
||||
|
||||
const longitude = z.number().finite().min(-180).max(180);
|
||||
const latitude = z.number().finite().min(-90).max(90);
|
||||
const coordinateSchema = z.tuple([longitude, latitude]);
|
||||
|
||||
const mapSchema = z.object({
|
||||
type: z.literal("map").default("map"),
|
||||
title: z.string().trim().max(200).default("地图"),
|
||||
center: coordinateSchema.optional(),
|
||||
zoom: z.number().finite().min(1).max(20).optional(),
|
||||
markers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: safeText,
|
||||
coordinate: coordinateSchema,
|
||||
description: optionalText,
|
||||
}),
|
||||
)
|
||||
.max(500)
|
||||
.default([]),
|
||||
lines: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().max(120).optional(),
|
||||
coordinates: z.array(coordinateSchema).min(2).max(2_000),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.default([]),
|
||||
polygons: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().max(120).optional(),
|
||||
coordinates: z.array(coordinateSchema).min(3).max(2_000),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export type MapSpec = z.infer<typeof mapSchema>;
|
||||
|
||||
export const MINDMAP_NODE_KINDS = [
|
||||
"topic",
|
||||
"dimension",
|
||||
"finding",
|
||||
"evidence",
|
||||
"risk",
|
||||
"action",
|
||||
] as const;
|
||||
|
||||
export type MindmapNodeKind = (typeof MINDMAP_NODE_KINDS)[number];
|
||||
|
||||
const mindmapNodeSchema = z.object({
|
||||
id: z.string().trim().min(1).max(80),
|
||||
parentId: z.string().trim().min(1).max(80).nullable(),
|
||||
label: z.string().trim().min(1).max(120),
|
||||
kind: z.enum(MINDMAP_NODE_KINDS).default("finding"),
|
||||
detail: z.string().trim().max(1_200).optional(),
|
||||
sourceRefs: z.array(z.string().trim().min(1).max(160)).max(12).optional(),
|
||||
});
|
||||
|
||||
const mindmapSchema = z.object({
|
||||
type: z.literal("mindmap").default("mindmap"),
|
||||
title: z.string().trim().max(200).default("分析思维导图"),
|
||||
summary: z.string().trim().max(800).optional(),
|
||||
rootId: z.string().trim().min(1).max(80).optional(),
|
||||
// A mind map is an overview, not a transcription of every paragraph in a
|
||||
// report. Keeping this small is also important because every node becomes a
|
||||
// rendered React Flow element in the message list and full-screen canvas.
|
||||
nodes: z.array(mindmapNodeSchema).min(1).max(36),
|
||||
});
|
||||
|
||||
export type MindmapSpec = z.infer<typeof mindmapSchema>;
|
||||
type MindmapNode = MindmapSpec["nodes"][number];
|
||||
export type VisualizationSpec = MapSpec | MindmapSpec;
|
||||
|
||||
const MINDMAP_ROOT_CHILD_LIMIT = 6;
|
||||
// The generation prompt still asks the model to target three children so the
|
||||
// overview stays compact. Real analysis dimensions can naturally contain four
|
||||
// equally important facts, though, and rejecting the entire graph for that
|
||||
// one-node overflow is too brittle. Keep a one-item rendering tolerance while
|
||||
// the global node, depth and cycle guards continue to bound the canvas.
|
||||
const MINDMAP_BRANCH_CHILD_LIMIT = 4;
|
||||
|
||||
export type VisualizationParseResult =
|
||||
| { ok: true; spec: VisualizationSpec }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function normalizeLanguageType(
|
||||
language: VisualizationLanguage,
|
||||
value: unknown,
|
||||
): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const record = { ...(value as Record<string, unknown>) };
|
||||
if (language === "map-json") record.type = "map";
|
||||
if (language === "mindmap-json") record.type = "mindmap";
|
||||
return record;
|
||||
}
|
||||
|
||||
function formatZodError(error: z.ZodError): string {
|
||||
const issue = error.issues[0];
|
||||
if (!issue) return "可视化数据格式不正确";
|
||||
const path = issue.path.length > 0 ? `${issue.path.join(".")}:` : "";
|
||||
return `${path}${issue.message}`;
|
||||
}
|
||||
|
||||
function validateMindmap(spec: MindmapSpec): string | null {
|
||||
const nodesById = new Map<string, MindmapSpec["nodes"][number]>();
|
||||
for (const node of spec.nodes) {
|
||||
if (nodesById.has(node.id)) return `节点 id 重复:${node.id}`;
|
||||
nodesById.set(node.id, node);
|
||||
}
|
||||
|
||||
const roots = spec.nodes.filter((node) => node.parentId === null);
|
||||
if (roots.length !== 1) return "思维导图必须且只能有一个根节点";
|
||||
const root = roots[0]!;
|
||||
if (spec.rootId && spec.rootId !== root.id) return "rootId 必须指向唯一的根节点";
|
||||
|
||||
for (const node of spec.nodes) {
|
||||
if (node.parentId === null) continue;
|
||||
if (node.parentId === node.id) return `节点不能以自身为父节点:${node.id}`;
|
||||
if (!nodesById.has(node.parentId)) return `节点 ${node.id} 的父节点不存在:${node.parentId}`;
|
||||
}
|
||||
|
||||
const children = new Map<string, MindmapNode[]>();
|
||||
for (const node of spec.nodes) {
|
||||
if (node.parentId === null) continue;
|
||||
const siblings = children.get(node.parentId) ?? [];
|
||||
siblings.push(node);
|
||||
children.set(node.parentId, siblings);
|
||||
}
|
||||
for (const [parentId, siblings] of children) {
|
||||
const limit =
|
||||
parentId === root.id
|
||||
? MINDMAP_ROOT_CHILD_LIMIT
|
||||
: MINDMAP_BRANCH_CHILD_LIMIT;
|
||||
if (siblings.length > limit) {
|
||||
const parentLabel = nodesById.get(parentId)?.label ?? parentId;
|
||||
return parentId === root.id
|
||||
? `中心主题最多保留 ${MINDMAP_ROOT_CHILD_LIMIT} 个一级维度,当前“${parentLabel}”下有 ${siblings.length} 个`
|
||||
: `节点“${parentLabel}”最多保留 ${MINDMAP_BRANCH_CHILD_LIMIT} 个子节点,当前有 ${siblings.length} 个;请合并同类信息`;
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of spec.nodes) {
|
||||
let depth = 0;
|
||||
let cursor: MindmapSpec["nodes"][number] | undefined = node;
|
||||
const visited = new Set<string>();
|
||||
while (cursor && cursor.parentId !== null) {
|
||||
if (visited.has(cursor.id)) return "思维导图不能包含循环父子关系";
|
||||
visited.add(cursor.id);
|
||||
cursor = nodesById.get(cursor.parentId);
|
||||
depth += 1;
|
||||
if (depth > 3) return "思维导图最多支持 4 层层级";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mindmapShapeError(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const nodes = (value as Record<string, unknown>).nodes;
|
||||
if (Array.isArray(nodes) && nodes.length > 36) {
|
||||
return `思维导图节点过多(${nodes.length} 个);请将同类信息合并后控制在 36 个以内`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseVisualizationSpec(
|
||||
language: VisualizationLanguage,
|
||||
source: string,
|
||||
): VisualizationParseResult {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
return { ok: false, error: "地图数据尚未完整或不是有效 JSON" };
|
||||
}
|
||||
|
||||
const normalized = normalizeLanguageType(language, raw);
|
||||
if (!normalized || typeof normalized !== "object" || Array.isArray(normalized)) {
|
||||
return { ok: false, error: "地图数据必须是 JSON 对象" };
|
||||
}
|
||||
|
||||
if (language === "mindmap-json") {
|
||||
const error = mindmapShapeError(normalized);
|
||||
if (error) return { ok: false, error };
|
||||
}
|
||||
|
||||
const schema = language === "map-json" ? mapSchema : mindmapSchema;
|
||||
const parsed = schema.safeParse(normalized);
|
||||
if (!parsed.success) return { ok: false, error: formatZodError(parsed.error) };
|
||||
if (language === "mindmap-json") {
|
||||
const error = validateMindmap(parsed.data as MindmapSpec);
|
||||
if (error) return { ok: false, error };
|
||||
}
|
||||
return { ok: true, spec: parsed.data };
|
||||
}
|
||||
|
||||
export type VisualizationSegment =
|
||||
| { type: "markdown"; content: string }
|
||||
| {
|
||||
type: "visualization";
|
||||
language: VisualizationLanguage;
|
||||
source: string;
|
||||
complete: boolean;
|
||||
};
|
||||
|
||||
// `mindmap-json` is the contract. `mindmap` is retained as a compatibility
|
||||
// alias because existing model turns have already emitted that fence name.
|
||||
const FENCE_PATTERN = /(^|\n)```(map-json|mindmap-json|mindmap|json)[ \t]*\r?\n/gi;
|
||||
|
||||
function isMapJsonSource(source: string): boolean {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(source);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const hasMapField = ["center", "zoom", "markers", "lines", "polygons"].some(
|
||||
(field) => Object.prototype.hasOwnProperty.call(record, field),
|
||||
);
|
||||
return hasMapField && parseVisualizationSpec("map-json", source).ok;
|
||||
}
|
||||
|
||||
/** Split only our dedicated fences. With no match, the caller keeps the exact legacy renderer path. */
|
||||
export function splitVisualizationContent(content: string): VisualizationSegment[] {
|
||||
const segments: VisualizationSegment[] = [];
|
||||
let cursor = 0;
|
||||
FENCE_PATTERN.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = FENCE_PATTERN.exec(content)) !== null) {
|
||||
const blockStart = match.index + match[1].length;
|
||||
if (blockStart > cursor) {
|
||||
segments.push({ type: "markdown", content: content.slice(cursor, blockStart) });
|
||||
}
|
||||
const sourceStart = FENCE_PATTERN.lastIndex;
|
||||
const closeIndex = content.indexOf("```", sourceStart);
|
||||
const complete = closeIndex >= 0;
|
||||
const source = content.slice(sourceStart, complete ? closeIndex : content.length).trim();
|
||||
const matchedLanguage = match[2].toLowerCase();
|
||||
const isMindmapFence = matchedLanguage === "mindmap-json" || matchedLanguage === "mindmap";
|
||||
// Dedicated mind-map JSON is shown as an ordinary streaming code block
|
||||
// until the closing fence arrives. That lets people see the model writing
|
||||
// and validate the raw structure before the expensive renderer mounts.
|
||||
if (isMindmapFence && !complete) {
|
||||
return [{ type: "markdown", content }];
|
||||
}
|
||||
if (matchedLanguage === "json" && (!complete || !isMapJsonSource(source))) {
|
||||
if (!complete) break;
|
||||
FENCE_PATTERN.lastIndex = closeIndex + 3;
|
||||
continue;
|
||||
}
|
||||
segments.push({
|
||||
type: "visualization",
|
||||
language: isMindmapFence ? "mindmap-json" : "map-json",
|
||||
source,
|
||||
complete,
|
||||
});
|
||||
cursor = complete ? closeIndex + 3 : content.length;
|
||||
FENCE_PATTERN.lastIndex = cursor;
|
||||
if (!complete) break;
|
||||
}
|
||||
|
||||
if (cursor < content.length) {
|
||||
segments.push({ type: "markdown", content: content.slice(cursor) });
|
||||
}
|
||||
return segments.length > 0 ? segments : [{ type: "markdown", content }];
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Landmark, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react";
|
||||
|
||||
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
|
||||
import { SidebarCollapseButton } from "./workspace-header";
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
applyCompactMenuOverrides,
|
||||
type CompactMenuItem,
|
||||
} from "@/core/page-layout/compact-menu-overrides";
|
||||
import { type MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||
import {
|
||||
MARKDOWN_DOCX_MENU_ITEM,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import { useCompactMenuOverrides } from "@/strategy-components/api/compact-menu-overrides";
|
||||
|
||||
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
|
||||
@@ -65,6 +68,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/chats",
|
||||
match: (pathname) => pathname === "/page/workspace/chats",
|
||||
},
|
||||
{
|
||||
id: "strategy-projects",
|
||||
label: "我的项目",
|
||||
icon: FolderKanban,
|
||||
path: "/page/strategy/qa/projects",
|
||||
match: (pathname) =>
|
||||
pathname.startsWith("/page/strategy/qa/projects") ||
|
||||
pathname.startsWith("/page/strategy/qa/strategy-selection"),
|
||||
},
|
||||
{
|
||||
id: "workspace-library",
|
||||
label: "资料库",
|
||||
@@ -86,13 +98,6 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/skills",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/skills"),
|
||||
},
|
||||
{
|
||||
id: "workspace-memory",
|
||||
label: "记忆",
|
||||
icon: BrainIcon,
|
||||
path: "/page/workspace/memory",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/memory"),
|
||||
},
|
||||
{
|
||||
id: "workspace-ai-writing",
|
||||
label: "AI写作",
|
||||
@@ -107,6 +112,13 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/deep-research",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/deep-research"),
|
||||
},
|
||||
{
|
||||
id: "workspace-gongwen",
|
||||
label: "公文助手",
|
||||
icon: Landmark,
|
||||
path: "/page/workspace/gongwen",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/gongwen"),
|
||||
},
|
||||
// {
|
||||
// id: "workspace-report-collaboration",
|
||||
// label: "报告协作",
|
||||
@@ -135,12 +147,18 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [
|
||||
path: "/page/workspace/scheduled/tasks",
|
||||
match: (pathname) => pathname === "/page/workspace/scheduled/tasks",
|
||||
},
|
||||
// {
|
||||
// id: "workspace-knowledge",
|
||||
// label: "知识库管理",
|
||||
// icon: BookOpen,
|
||||
// path: "/page/workspace/knowledge",
|
||||
// match: (pathname) =>
|
||||
// pathname.startsWith("/page/workspace/knowledge") &&
|
||||
// !pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
// },
|
||||
{
|
||||
id: "workspace-knowledge",
|
||||
label: "知识库管理",
|
||||
icon: BookOpen,
|
||||
path: "/page/workspace/knowledge",
|
||||
match: (pathname) => pathname.startsWith("/page/workspace/knowledge"),
|
||||
...MARKDOWN_DOCX_MENU_ITEM,
|
||||
match: (pathname) => pathname.startsWith(MARKDOWN_DOCX_MENU_ITEM.path),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -151,7 +169,8 @@ export function useWorkspaceCompactNavItems(): {
|
||||
settingsItems: EffectiveWorkspaceCompactNavItem[];
|
||||
toolboxItems: EffectiveWorkspaceCompactNavItem[];
|
||||
} {
|
||||
const { data: overrides = [] } = useCompactMenuOverrides();
|
||||
const { data: overrideConfig } = useCompactMenuOverrides();
|
||||
const overrides = overrideConfig?.overrides ?? [];
|
||||
return useMemo(() => {
|
||||
const applied = applyCompactMenuOverrides(WORKSPACE_COMPACT_NAV_ITEMS, overrides);
|
||||
return {
|
||||
|
||||
@@ -20,9 +20,10 @@ import {
|
||||
SettingsIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
TimerIcon,
|
||||
TrophyIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import {
|
||||
@@ -92,11 +93,34 @@ export function WorkspaceNavMenu() {
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const { settingsItems } = useWorkspaceCompactNavItems();
|
||||
// 整页缩放时菜单会盖住按钮。按下后松手若落在菜单项上,Radix 会当成选中并立刻关闭。
|
||||
// 这次打开手势的 pointerup / click 不传给菜单项。
|
||||
const openingGesture = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const markOpeningGesture = () => {
|
||||
openingGesture.current = true;
|
||||
window.setTimeout(() => {
|
||||
openingGesture.current = false;
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const swallowOpeningPointerUp = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
if (!openingGesture.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const swallowOpeningClick = (event: { preventDefault: () => void; stopPropagation: () => void }) => {
|
||||
if (!openingGesture.current) return;
|
||||
openingGesture.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// 简洁模式界面引导:已在问答页(ChatPage 挂载中)时直接广播事件启动;否则先落
|
||||
// sessionStorage 请求,导航到新对话页后由 CompactModeGuide 消费启动。
|
||||
const startCompactGuide = () => {
|
||||
@@ -140,19 +164,23 @@ export function WorkspaceNavMenu() {
|
||||
<SidebarMenu className="w-full" data-compact-guide="settings-menu">
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
onPointerDown={markOpeningGesture}
|
||||
>
|
||||
<NavMenuButtonContent isSidebarOpen={isSidebarOpen} t={t} />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
sideOffset={8}
|
||||
onPointerUpCapture={swallowOpeningPointerUp}
|
||||
onClickCapture={swallowOpeningClick}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
{settingsItems.map((item) => {
|
||||
@@ -223,7 +251,7 @@ export function WorkspaceNavMenu() {
|
||||
{isAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard")
|
||||
navigate("/page/workspace/admin/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
@@ -265,6 +293,22 @@ export function WorkspaceNavMenu() {
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skill-timing")}
|
||||
>
|
||||
<TimerIcon />
|
||||
技能耗时排查
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/model-exchanges")}
|
||||
>
|
||||
<ScrollTextIcon />
|
||||
模型收发记录
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/models")}
|
||||
@@ -273,16 +317,15 @@ export function WorkspaceNavMenu() {
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口(管理员):
|
||||
简洁模式没有全局侧边栏,这里放一份保证两种模式都能进菜单管理。 */}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/admin/menu-management")}
|
||||
>
|
||||
<ListTreeIcon />
|
||||
菜单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* 与全量模式左下角「设置和更多」下拉里的菜单管理同入口:
|
||||
简洁模式没有全局侧边栏,走工作区地址带上 WorkspaceSidebar;
|
||||
/page/strategy/admin/menu-management 保留给全量模式侧边栏。 */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/menu-management")}
|
||||
>
|
||||
<ListTreeIcon />
|
||||
{isAdmin ? "默认菜单管理" : "我的菜单"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
@@ -328,7 +371,7 @@ export function WorkspaceNavMenu() {
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/fixed-questions")
|
||||
navigate("/page/workspace/admin/fixed-questions")
|
||||
}
|
||||
>
|
||||
<MessageSquareTextIcon />
|
||||
|
||||
@@ -21,11 +21,14 @@ import { WorkspaceNavMenu } from "./workspace-nav-menu";
|
||||
export function WorkspaceSidebar({
|
||||
compactChrome = false,
|
||||
forceCollapsed = false,
|
||||
wxCommonAgents = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar> & {
|
||||
compactChrome?: boolean;
|
||||
forceCollapsed?: boolean;
|
||||
/** Enables wx's shared Agent Gallery category selection. */
|
||||
wxCommonAgents?: boolean;
|
||||
}) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
@@ -35,6 +38,7 @@ export function WorkspaceSidebar({
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;点击常用智能体后是
|
||||
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
|
||||
const isGeneralQa =
|
||||
wxCommonAgents ||
|
||||
pathname.startsWith("/page/workspace/chats") ||
|
||||
pathname.startsWith("/page/strategy/chats");
|
||||
|
||||
@@ -84,7 +88,7 @@ export function WorkspaceSidebar({
|
||||
{isGeneralQa ? (
|
||||
// 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
|
||||
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
|
||||
<GeneralQaSidebar />
|
||||
<GeneralQaSidebar wxCommonAgents={wxCommonAgents} />
|
||||
) : (
|
||||
<>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface ModelExchangeRecord {
|
||||
id: string;
|
||||
at: string;
|
||||
model: string;
|
||||
user_id: string;
|
||||
enable_thinking: string;
|
||||
request_preview: string;
|
||||
response_preview: string;
|
||||
request_text: string;
|
||||
request_body: string;
|
||||
response_text: string;
|
||||
response_raw: string;
|
||||
response_content: string;
|
||||
response_reasoning: string;
|
||||
reasoning_field: string;
|
||||
}
|
||||
|
||||
export async function loadModelExchanges(): Promise<ModelExchangeRecord[]> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/model-exchanges`);
|
||||
if (!response.ok) {
|
||||
throw new Error("模型收发记录读取失败");
|
||||
}
|
||||
const body = (await response.json()) as { records?: ModelExchangeRecord[] };
|
||||
return body.records ?? [];
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** One completed sandbox call. Written only after that call returns. */
|
||||
export interface SkillViewEvent {
|
||||
at: string;
|
||||
status: string;
|
||||
total_ms: number;
|
||||
lock_wait_ms: number;
|
||||
refresh_ms: number;
|
||||
source_entries: number;
|
||||
target_entries: number;
|
||||
enabled_count: number;
|
||||
path_count: number;
|
||||
tool: string;
|
||||
command: string;
|
||||
skill_names: string[];
|
||||
tool_ms: number;
|
||||
explanation: {
|
||||
level: string;
|
||||
headline: string;
|
||||
look_at: string;
|
||||
cause: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SkillViewCheck {
|
||||
id: string;
|
||||
title: string;
|
||||
result: string;
|
||||
level: string;
|
||||
look_at: string;
|
||||
cause: string;
|
||||
command: string;
|
||||
skill_names: string[];
|
||||
}
|
||||
|
||||
export interface SkillViewDiagnostics {
|
||||
cache_enabled: boolean;
|
||||
reuse_enabled: boolean;
|
||||
level: string;
|
||||
conclusion: string;
|
||||
checks: SkillViewCheck[];
|
||||
events: SkillViewEvent[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
sort: "time" | "duration" | "check";
|
||||
order: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface SkillViewQuery {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sort: "time" | "duration" | "check";
|
||||
order: "asc" | "desc";
|
||||
}
|
||||
|
||||
/** Polled by the admin timing page. Not an operation log. */
|
||||
export async function loadSkillViewDiagnostics(query: SkillViewQuery): Promise<SkillViewDiagnostics> {
|
||||
const params = new URLSearchParams({
|
||||
page: String(query.page),
|
||||
page_size: String(query.pageSize),
|
||||
sort: query.sort,
|
||||
order: query.order,
|
||||
});
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/skill-view-diagnostics?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("技能耗时记录读取失败");
|
||||
}
|
||||
return (await response.json()) as SkillViewDiagnostics;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { mergeConversationAgentOptions } from "./conversation-options";
|
||||
import type { Agent } from "./types";
|
||||
|
||||
function agent(
|
||||
id: string,
|
||||
options: Partial<Agent> = {},
|
||||
): Agent {
|
||||
return {
|
||||
id,
|
||||
name: options.name ?? id,
|
||||
description: "",
|
||||
user_id: options.user_id ?? null,
|
||||
published: options.published ?? true,
|
||||
is_private: options.is_private ?? false,
|
||||
builtin: options.builtin ?? options.user_id == null,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
test("keeps curated built-ins and appends every visible custom agent", () => {
|
||||
const featured = [
|
||||
agent("featured-builtin", { builtin: true }),
|
||||
agent("featured-custom", { builtin: false, user_id: "owner-1" }),
|
||||
];
|
||||
const visible = [
|
||||
agent("internal-worker", { builtin: true }),
|
||||
agent("featured-custom", { builtin: false, user_id: "owner-1" }),
|
||||
agent("owned-private", {
|
||||
builtin: false,
|
||||
user_id: "owner-1",
|
||||
published: false,
|
||||
}),
|
||||
agent("published-custom", {
|
||||
builtin: false,
|
||||
user_id: "owner-2",
|
||||
}),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
mergeConversationAgentOptions(featured, visible).map(({ id }) => id),
|
||||
[
|
||||
"featured-builtin",
|
||||
"featured-custom",
|
||||
"owned-private",
|
||||
"published-custom",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("deduplicates ids and ignores malformed unavailable rows", () => {
|
||||
const duplicate = agent("agent-1", {
|
||||
name: "精选名称",
|
||||
builtin: false,
|
||||
user_id: "owner-1",
|
||||
});
|
||||
|
||||
const result = mergeConversationAgentOptions(
|
||||
[duplicate],
|
||||
[
|
||||
agent("agent-1", {
|
||||
name: "列表名称",
|
||||
builtin: false,
|
||||
user_id: "owner-1",
|
||||
}),
|
||||
agent("", { builtin: false, user_id: "owner-1" }),
|
||||
agent("missing-name", {
|
||||
name: " ",
|
||||
builtin: false,
|
||||
user_id: "owner-1",
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0]?.name, "精选名称");
|
||||
});
|
||||
Loaded 100 of 1651 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user