merge: 0629001 into 0629002

合并反馈、语音 ASR、MindSpace 修复等 0629001 发布改动,并与 Agent Runs 网关改动完成冲突解决。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-29 22:51:03 +08:00
101 changed files with 20868 additions and 2592 deletions
+24
View File
@@ -9,6 +9,7 @@ import { MindSpaceView } from './components/MindSpaceView';
import { ChatProvider } from './context/ChatProvider';
import { PREVIEW_USER } from './dev/mindspacePreviewData';
import { MindSpaceRoute } from './routes/MindSpaceRoute';
import { FeedbackRoutes } from './routes/FeedbackRoute';
import type { CapabilityMap, PortalUser } from './types';
function isMindSpacePreview() {
@@ -86,6 +87,7 @@ function AuthenticatedApp({
? () => { window.location.href = import.meta.env.VITE_ADMIN_APP_URL ?? '/admin-app'; }
: undefined
}
onOpenFeedback={() => navigate('/feedback')}
onLogout={handleLogout}
/>
);
@@ -102,6 +104,10 @@ function AuthenticatedApp({
path="/space/*"
element={<MindSpaceAuthGate user={user} onLogout={handleLogout} />}
/>
<Route
path="/feedback/*"
element={<FeedbackRoutes user={user} onLogout={handleLogout} />}
/>
<Route path="/" element={chatElement} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -117,6 +123,7 @@ export function App() {
const [capabilities, setCapabilities] = useState<CapabilityMap | undefined>();
const [grantedSkills, setGrantedSkills] = useState<string[] | undefined>();
const [legacyMode, setLegacyMode] = useState(false);
const [authUnavailable, setAuthUnavailable] = useState<string | null>(null);
useEffect(() => {
if (mindSpacePreview) return;
@@ -128,6 +135,12 @@ export function App() {
}
});
void checkAuth().then((status) => {
if (status.mode === 'unavailable') {
setAuthUnavailable(status.message ?? '用户认证服务暂时不可用,请稍后刷新');
setAuthed(false);
return;
}
setAuthUnavailable(null);
setLegacyMode(status.mode === 'legacy');
setAuthed(status.authenticated);
setUser(status.user ?? null);
@@ -159,6 +172,17 @@ export function App() {
return <div className="app-loading">正在验证登录状态…</div>;
}
if (authUnavailable) {
return (
<div className="app-loading">
{authUnavailable}
<button type="button" className="login-link" onClick={() => window.location.reload()}>
重新加载
</button>
</div>
);
}
if (!authed) {
return (
<AuthView
+139 -24
View File
@@ -36,8 +36,16 @@ import type {
PlazaPostBrief,
MindSpaceRedactedCopyResult,
MindSpaceUpload,
FeedbackContextInput,
FeedbackImageInput,
FeedbackSubmission,
FeedbackSubmissionType,
FeedbackBoardPage,
PortalUser,
Session,
SessionConversationPage,
SessionListPage,
SessionSummary,
SessionEvent,
SessionListResponse,
UsageRecord,
@@ -45,8 +53,9 @@ import type {
UserNotification,
PlanDefinition,
ActiveSubscription,
UserMemorySyncResponse,
} from '../types';
import { CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES } from '../utils/imageUpload';
import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES } from '../utils/imageUpload';
import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message';
const API = '/api';
@@ -229,7 +238,10 @@ async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
if (res.status === 204) return undefined as T;
const text = await res.text().catch(() => '');
if (text.trimStart().startsWith('<')) {
throw new ApiError(res.status, '服务器返回了意外的页面,请确认后端服务已启动并已重启');
throw new ApiError(
res.status,
`接口 ${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs)`,
);
}
try {
return JSON.parse(text) as T;
@@ -287,7 +299,10 @@ async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
if (res.status === 204) return undefined as T;
const rawText = await res.text().catch(() => '');
if (rawText.trimStart().startsWith('<')) {
throw new ApiError(res.status, '服务器返回了意外的页面,请确认后端服务已启动并已重启');
throw new ApiError(
res.status,
`接口 ${API}${path} 返回了页面而非 JSON,请重启后端(pnpm dev 或 node server.mjs)`,
);
}
try {
return JSON.parse(rawText) as T;
@@ -325,6 +340,20 @@ export async function rememberProjectContext(
});
}
export async function rememberUserMemory(sessionId: string): Promise<UserMemorySyncResponse> {
return apiFetch<UserMemorySyncResponse>('/user-memory/v1/remember-recent', {
method: 'POST',
body: JSON.stringify({ sessionId }),
});
}
export async function syncUserMemory(sessionId: string): Promise<UserMemorySyncResponse> {
return apiFetch<UserMemorySyncResponse>('/user-memory/v1/sync', {
method: 'POST',
body: JSON.stringify({ sessionId }),
});
}
export type WechatAuthConfig = {
enabled: boolean;
inWechat?: boolean;
@@ -395,8 +424,11 @@ export async function getWechatJsSdkSignature(url: string): Promise<WechatJsSdkS
export async function checkAuth(): Promise<AuthStatus> {
try {
const response = await fetch('/auth/status');
if (!response.ok) return { authenticated: false };
const status = (await response.json()) as AuthStatus;
if (!response.ok) {
if (status.mode === 'unavailable') return status;
return { authenticated: false };
}
if (status.authenticated) resetUnauthorizedGuard();
if (status.authenticated && status.mode === 'user' && !status.capabilities) {
try {
@@ -431,6 +463,53 @@ export async function getMyUsage(): Promise<UsageRecord[]> {
return result.records ?? [];
}
export async function submitFeedback(input: {
type: FeedbackSubmissionType;
title: string;
description: string;
contact?: string;
images?: FeedbackImageInput[];
context?: FeedbackContextInput;
}): Promise<FeedbackSubmission> {
const result = await portalFetch<{ feedback: FeedbackSubmission }>('/auth/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: input.type,
title: input.title,
description: input.description,
contact: input.contact,
images: input.images,
context: input.context,
}),
});
return result.feedback;
}
export async function getMyFeedback(limit = 20): Promise<FeedbackSubmission[]> {
const result = await portalFetch<{ items: FeedbackSubmission[] }>(
`/auth/feedback?limit=${encodeURIComponent(String(limit))}`,
);
return result.items ?? [];
}
export async function listFeedbackBoard(page = 1, limit = 10): Promise<FeedbackBoardPage> {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
});
return portalFetch<FeedbackBoardPage>(`/auth/feedback/board?${params.toString()}`);
}
export async function getFeedbackDetail(feedbackId: string): Promise<{
item: FeedbackSubmission;
isMine: boolean;
}> {
return portalFetch<{ item: FeedbackSubmission; isMine: boolean }>(
`/auth/feedback/${encodeURIComponent(feedbackId)}`,
);
}
export async function getMyBillingLedger(limit = 30): Promise<LedgerEntry[]> {
const query = limit ? `?limit=${encodeURIComponent(String(limit))}` : '';
const result = await portalFetch<{ entries: LedgerEntry[] }>(`/auth/billing/ledger${query}`);
@@ -536,11 +615,13 @@ export async function listMindSpaceAssets(
export async function uploadMindSpaceAsset(
categoryId: string,
file: File,
options: { maxImageBytes?: number; onProgress?: (progress: number) => void } = {},
): Promise<MindSpaceAsset> {
if (file.type.startsWith('image/') && file.size > CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES) {
const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES;
if (file.type.startsWith('image/') && file.size > maxImageBytes) {
throw new ApiError(
413,
`图片文件过大,当前 ${(file.size / 1024 / 1024).toFixed(2)}MB,请先压缩到 ${CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES / 1024 / 1024}MB 以下再上传。`,
`图片文件过大,当前 ${(file.size / 1024 / 1024).toFixed(2)}MB,超过 ${maxImageBytes / 1024 / 1024}MB 上传上限。`,
);
}
@@ -555,20 +636,7 @@ export async function uploadMindSpaceAsset(
});
try {
const contentResponse = await fetch(created.data.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/octet-stream' },
body: file,
});
if (!contentResponse.ok) {
const body = (await contentResponse.json().catch(() => null)) as {
error?: { message?: string };
} | null;
throw new ApiError(
contentResponse.status,
body?.error?.message ?? '文件内容上传失败',
);
}
await uploadFileContent(created.data.uploadUrl, file, options.onProgress);
const completed = await apiFetch<{ data: MindSpaceAsset }>(
`/mindspace/v1/uploads/${created.data.id}/complete`,
{ method: 'POST', body: JSON.stringify({}) },
@@ -582,6 +650,40 @@ export async function uploadMindSpaceAsset(
}
}
function uploadFileContent(
url: string,
file: File,
onProgress?: (progress: number) => void,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable || !onProgress) return;
onProgress(Math.min(0.99, Math.max(0, event.loaded / event.total)));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
onProgress?.(1);
resolve();
return;
}
let message = '文件内容上传失败';
try {
const body = JSON.parse(xhr.responseText || '{}') as { error?: { message?: string } };
message = body?.error?.message ?? message;
} catch {
// Keep the generic upload error when the response is not JSON.
}
reject(new ApiError(xhr.status, message));
};
xhr.onerror = () => reject(new ApiError(0, '文件内容上传失败'));
xhr.onabort = () => reject(new ApiError(0, '文件上传已取消'));
xhr.send(file);
});
}
export async function deleteMindSpaceAsset(assetId: string): Promise<void> {
await apiFetch(`/mindspace/v1/assets/${assetId}`, { method: 'DELETE' });
}
@@ -2018,9 +2120,18 @@ export async function applyLocalLlmFallback(sessionId: string): Promise<{
});
}
export async function listSessions(): Promise<Session[]> {
const result = await apiFetch<SessionListResponse>('/sessions');
return result.sessions ?? [];
export async function listSessions(options?: {
limit?: number;
offset?: number;
query?: string;
}): Promise<{ items: SessionSummary[]; page: SessionListPage }> {
const params = new URLSearchParams();
if (options?.limit != null) params.set('limit', String(options.limit));
if (options?.offset != null) params.set('offset', String(options.offset));
if (options?.query?.trim()) params.set('query', options.query.trim());
const qs = params.size ? `?${params.toString()}` : '';
const result = await apiFetch<SessionListResponse>(`/sessions${qs}`);
return { items: result.sessions ?? [], page: result.page ?? {} };
}
function sessionPath(sessionId: string, suffix = '') {
@@ -2038,19 +2149,23 @@ export async function deleteChatSession(sessionId: string): Promise<void> {
export async function loadSessionDetail(
sessionId: string,
hints?: { messageCount?: number; updatedAt?: string },
history?: { before?: number; limit?: number },
): Promise<{
session: Session;
messages: Message[];
page: SessionConversationPage;
}> {
const params = new URLSearchParams();
if (hints?.messageCount != null) params.set('hint_mc', String(hints.messageCount));
if (hints?.updatedAt) params.set('hint_ua', hints.updatedAt);
if (history?.before != null) params.set('history_before', String(history.before));
if (history?.limit != null) params.set('history_limit', String(history.limit));
const qs = params.size ? `?${params.toString()}` : '';
const detail = await apiFetch<Session>(`${sessionPath(sessionId)}${qs}`);
const messages = normalizeConversationMessages(
(detail.conversation ?? []).filter((m) => m.metadata?.userVisible),
);
return { session: detail, messages };
return { session: detail, messages, page: detail.conversation_page ?? {} };
}
export async function createAgentRun(
+314 -105
View File
@@ -1,7 +1,12 @@
import { ChangeEvent, useEffect, useRef, useState, type ClipboardEvent } from 'react';
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
import {
CHAT_IMAGE_UPLOAD_MAX_COUNT,
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES,
} from '../utils/imageUpload';
import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
import { DesignSkillPanel } from './DesignSkillPanel';
@@ -11,7 +16,6 @@ import { VoiceInputButton } from './VoiceInputButton';
import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
import type { MindSpaceSaveCategory } from '../types';
const MAX_PENDING_IMAGES = 6;
const CHAT_PLACEHOLDER_PROMPTS = [
'帮我写一篇温柔一点的小短文',
'讲个轻松的笑话,让我换换脑子',
@@ -47,11 +51,19 @@ const CHAT_PLACEHOLDER_PROMPTS = [
type PendingChatImage = {
id: string;
url: string;
file: File;
previewUrl: string;
uploading: boolean;
sizeBytes: number;
uploadedUrl: string | null;
uploadProgress: number | null;
uploadStatus: 'queued' | 'uploading' | 'uploaded' | 'error';
};
function formatUploadMegabytes(bytes: number) {
const megabytes = bytes / (1024 * 1024);
return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)}MB`;
}
function FileIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
@@ -68,6 +80,9 @@ export function ChatPanel({
variant,
user,
messages,
historyLoadingMore = false,
historyHasMore = false,
historyTotal = 0,
chatState,
pendingTool,
session,
@@ -75,6 +90,7 @@ export function ChatPanel({
grantedSkills,
onSubmit,
onUploadImage,
onLoadOlderMessages,
onStop,
onApproveTool,
onPageSaved,
@@ -83,17 +99,21 @@ export function ChatPanel({
variant: 'full' | 'compact';
user?: PortalUser | null;
messages: Message[];
historyLoadingMore?: boolean;
historyHasMore?: boolean;
historyTotal?: number;
chatState: ChatState;
pendingTool: ToolConfirmation | null;
session: Session | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
onSubmit: (text: string, imageUrls?: string[], previewImageUrls?: string[]) => void | Promise<void>;
onUploadImage?: (file: File) => Promise<string>;
onUploadImage?: (file: File, onProgress?: (progress: number) => void) => Promise<string>;
onLoadOlderMessages?: () => void | Promise<void>;
onStop: () => void | Promise<void>;
onApproveTool: (allow: boolean) => void | Promise<void>;
onPageSaved?: (result: {
kind: 'page' | 'category';
kind: 'page' | 'asset' | 'category';
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
@@ -108,16 +128,25 @@ export function ChatPanel({
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
const [imageError, setImageError] = useState<string | null>(null);
const [showHistoryFullyLoadedNotice, setShowHistoryFullyLoadedNotice] = useState(false);
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
);
const imageInputRef = useRef<HTMLInputElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLElement>(null);
const inputRef = useRef(input);
const voiceBaseRef = useRef('');
const pendingImagesRef = useRef(pendingImages);
const suppressVoiceUpdateRef = useRef(false);
const nearBottomRef = useRef(true);
const initializedSessionRef = useRef<string | null>(null);
const pendingOlderLoadRef = useRef<null | {
anchorId: string | null;
anchorTop: number;
remainingHeight: number;
}>(null);
const prevMessageCountRef = useRef(messages.length);
inputRef.current = input;
pendingImagesRef.current = pendingImages;
@@ -130,9 +159,123 @@ export function ChatPanel({
};
}, []);
const requestOlderMessages = useCallback(() => {
const container = mainRef.current;
if (!container || !onLoadOlderMessages || historyLoadingMore || !historyHasMore) return;
const blocks = Array.from(container.querySelectorAll<HTMLElement>('[data-message-anchor]'));
const anchor =
blocks.find((block) => {
const rect = block.getBoundingClientRect();
return rect.bottom >= container.getBoundingClientRect().top + 8;
}) ?? null;
pendingOlderLoadRef.current = {
anchorId: anchor?.dataset.messageAnchor ?? null,
anchorTop: anchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top,
remainingHeight: Math.max(container.clientHeight * 2, 1),
};
void onLoadOlderMessages();
}, [historyHasMore, historyLoadingMore, onLoadOlderMessages]);
const handleMainScroll = useCallback(() => {
const container = mainRef.current;
if (!container) return;
nearBottomRef.current =
container.scrollHeight - container.scrollTop - container.clientHeight < 120;
if (
container.scrollTop < 120 &&
historyHasMore &&
!historyLoadingMore &&
onLoadOlderMessages &&
!pendingOlderLoadRef.current
) {
requestOlderMessages();
}
}, [historyHasMore, historyLoadingMore, onLoadOlderMessages, requestOlderMessages]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, chatState, pendingTool]);
initializedSessionRef.current = null;
pendingOlderLoadRef.current = null;
nearBottomRef.current = true;
setShowHistoryFullyLoadedNotice(false);
}, [session?.id]);
useEffect(() => {
const isHomeWelcome = variant !== 'compact' && messages.length === 0;
if (isHomeWelcome || historyTotal <= 0 || historyHasMore || historyLoadingMore) {
setShowHistoryFullyLoadedNotice(false);
return;
}
setShowHistoryFullyLoadedNotice(true);
const timer = window.setTimeout(() => {
setShowHistoryFullyLoadedNotice(false);
}, 5000);
return () => window.clearTimeout(timer);
}, [historyHasMore, historyLoadingMore, historyTotal, messages.length, variant]);
useLayoutEffect(() => {
const container = mainRef.current;
if (!container) return;
const pendingOlderLoad = pendingOlderLoadRef.current;
if (pendingOlderLoad) {
const anchor = pendingOlderLoad.anchorId
? container.querySelector<HTMLElement>(`[data-message-anchor="${pendingOlderLoad.anchorId}"]`)
: null;
const anchorTop = anchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top;
const addedHeight = Math.max(0, anchorTop - pendingOlderLoad.anchorTop);
container.scrollTop += addedHeight;
if (pendingOlderLoad.remainingHeight - addedHeight > 0 && historyHasMore && !historyLoadingMore) {
const nextAnchor =
anchor ??
container.querySelector<HTMLElement>('[data-message-anchor]');
pendingOlderLoadRef.current = {
anchorId: nextAnchor?.dataset.messageAnchor ?? null,
anchorTop: nextAnchor?.getBoundingClientRect().top ?? container.getBoundingClientRect().top,
remainingHeight: pendingOlderLoad.remainingHeight - addedHeight,
};
requestOlderMessages();
return;
}
pendingOlderLoadRef.current = null;
nearBottomRef.current =
container.scrollHeight - container.scrollTop - container.clientHeight < 120;
return;
}
if (session?.id && initializedSessionRef.current !== session.id) {
container.scrollTop = container.scrollHeight;
nearBottomRef.current = true;
if (
container.scrollHeight < container.clientHeight * 2 &&
historyHasMore &&
!historyLoadingMore &&
onLoadOlderMessages
) {
requestOlderMessages();
return;
}
initializedSessionRef.current = session.id;
prevMessageCountRef.current = messages.length;
return;
}
const messageCountIncreased = messages.length > prevMessageCountRef.current;
if ((nearBottomRef.current || chatState === 'streaming') && messageCountIncreased) {
container.scrollTop = container.scrollHeight;
nearBottomRef.current = true;
}
prevMessageCountRef.current = messages.length;
}, [
chatState,
historyHasMore,
historyLoadingMore,
messages.length,
onLoadOlderMessages,
requestOlderMessages,
session?.id,
]);
const busy =
chatState === 'streaming' ||
@@ -193,16 +336,71 @@ export function ChatPanel({
if (item.previewUrl.startsWith('blob:')) URL.revokeObjectURL(item.previewUrl);
};
const pendingImageBytes = pendingImages.reduce((sum, item) => sum + item.sizeBytes, 0);
const planImageUploads = useCallback((files: File[]) => {
const remainingCount = CHAT_IMAGE_UPLOAD_MAX_COUNT - pendingImages.length;
if (remainingCount <= 0) {
return {
accepted: [],
message: `最多支持一次性附加 ${CHAT_IMAGE_UPLOAD_MAX_COUNT} 张图片`,
};
}
const accepted: File[] = [];
let nextTotalBytes = pendingImageBytes;
let oversizedRejected = false;
let totalRejected = false;
let countRejected = false;
for (const file of files) {
if (accepted.length >= remainingCount) {
countRejected = true;
continue;
}
if (file.size > CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES) {
oversizedRejected = true;
continue;
}
if (nextTotalBytes + file.size > CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES) {
totalRejected = true;
continue;
}
accepted.push(file);
nextTotalBytes += file.size;
}
if (accepted.length === 0) {
if (oversizedRejected) {
return {
accepted,
message: `单张图片不能超过 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES)}`,
};
}
return {
accepted,
message: `图片总大小不能超过 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES)}`,
};
}
let message = null;
if (countRejected) {
message = `最多支持 ${CHAT_IMAGE_UPLOAD_MAX_COUNT} 张图片,本次仅保留前 ${accepted.length} 张`;
} else if (oversizedRejected || totalRejected) {
message = `已按单张 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES)}、总计 ${formatUploadMegabytes(CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES)} 的限制筛选图片`;
}
return { accepted, message };
}, [pendingImageBytes, pendingImages.length]);
const handleSubmit = async () => {
const trimmed = input.trim();
if ((!trimmed && pendingImages.length === 0) || voiceDisabled) return;
if (uploadingImage || pendingImages.some((item) => item.uploading)) {
setImageError('图片仍在上传中,请稍候再发送');
if (pendingImages.length > 0 && !onUploadImage) {
setImageError('当前会话暂不支持图片发送');
return;
}
const imagesToSend = pendingImages.map((item) => item.url);
const previewImagesToSend = pendingImages.map((item) => item.previewUrl);
const sentImages = [...pendingImages];
let uploadedImages = sentImages;
suppressVoiceUpdateRef.current = true;
if (voiceRecording) {
@@ -212,17 +410,69 @@ export function ChatPanel({
voiceBaseRef.current = '';
setVoiceNotice(null);
setImageError(null);
setUploadingImage(true);
try {
const uploadedUrls = await Promise.all(
sentImages.map(async (item) => {
if (item.uploadedUrl) return item.uploadedUrl;
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? { ...candidate, uploadStatus: 'uploading', uploadProgress: 0 }
: candidate,
),
);
const uploadedUrl = await onUploadImage!(item.file, (progress) => {
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? {
...candidate,
uploadStatus: 'uploading',
uploadProgress: Math.round(progress * 100),
}
: candidate,
),
);
});
setPendingImages((current) =>
current.map((candidate) =>
candidate.id === item.id
? {
...candidate,
uploadedUrl,
uploadStatus: 'uploaded',
uploadProgress: 100,
}
: candidate,
),
);
return uploadedUrl;
}),
);
uploadedImages = sentImages.map((item, index) => ({
...item,
uploadedUrl: uploadedUrls[index] ?? item.uploadedUrl,
uploadProgress: uploadedUrls[index] ? 100 : item.uploadProgress,
uploadStatus: uploadedUrls[index] ? 'uploaded' : item.uploadStatus,
}));
const imagesToSend = uploadedUrls.filter(Boolean);
const previewImagesToSend = imagesToSend;
await onSubmit(trimmed, imagesToSend, previewImagesToSend);
sentImages.forEach(revokePendingImage);
uploadedImages.forEach(revokePendingImage);
setPendingImages([]);
} catch (err) {
suppressVoiceUpdateRef.current = false;
setInput(trimmed);
setPendingImages(sentImages);
setPendingImages(uploadedImages.map((item) => ({
...item,
uploadStatus: item.uploadedUrl ? 'uploaded' : 'error',
})));
setImageError(err instanceof Error ? err.message : '发送失败,请重试');
setUploadingImage(false);
return;
}
setUploadingImage(false);
suppressVoiceUpdateRef.current = false;
};
@@ -238,56 +488,23 @@ export function ChatPanel({
return;
}
const remaining = MAX_PENDING_IMAGES - pendingImages.length;
if (remaining <= 0) {
setImageError(`最多支持一次性附加 ${MAX_PENDING_IMAGES} 张图片`);
const { accepted: toQueue, message } = planImageUploads(imageFiles);
if (toQueue.length === 0) {
setImageError(message);
return;
}
setImageError(message);
const toUpload = imageFiles.slice(0, remaining);
if (imageFiles.length > remaining) {
setImageError(`最多支持 ${MAX_PENDING_IMAGES} 张图片,本次仅上传前 ${remaining} 张`);
} else {
setImageError(null);
}
const placeholders = toUpload.map((file, index) => ({
const placeholders = toQueue.map((file, index) => ({
id: `${Date.now()}-${index}-${file.name}`,
file,
previewUrl: URL.createObjectURL(file),
url: '',
uploading: true,
sizeBytes: file.size,
uploadedUrl: null,
uploadProgress: null,
uploadStatus: 'queued' as const,
}));
setPendingImages((prev) => [...prev, ...placeholders]);
setUploadingImage(true);
try {
const uploaded = await Promise.all(toUpload.map((file) => onUploadImage(file)));
const placeholderIds = placeholders.map((item) => item.id);
setPendingImages((prev) => {
const next = prev.map((item) => {
const index = placeholderIds.indexOf(item.id);
if (index < 0 || !uploaded[index]) return item;
return { ...item, url: uploaded[index], uploading: false };
});
const seen = new Set<string>();
return next.filter((item) => {
if (seen.has(item.url)) return false;
seen.add(item.url);
return true;
});
});
} catch (err) {
setPendingImages((prev) => {
const removeIds = new Set(placeholders.map((item) => item.id));
prev.forEach((item) => {
if (removeIds.has(item.id)) revokePendingImage(item);
});
return prev.filter((item) => !removeIds.has(item.id));
});
setImageError(err instanceof Error ? err.message : '图片上传失败');
} finally {
setUploadingImage(false);
}
};
const handlePaste = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
@@ -303,56 +520,21 @@ export function ChatPanel({
.filter((file): file is File => Boolean(file));
if (files.length === 0) return;
const remaining = MAX_PENDING_IMAGES - pendingImages.length;
if (remaining <= 0) {
setImageError(`最多支持一次性附加 ${MAX_PENDING_IMAGES} 张图片`);
const { accepted: toQueue, message } = planImageUploads(files);
if (toQueue.length === 0) {
setImageError(message);
return;
}
setImageError(message);
const toUpload = files.slice(0, remaining);
if (files.length > remaining) {
setImageError(`最多支持 ${MAX_PENDING_IMAGES} 张图片,本次仅粘贴前 ${remaining} 张`);
} else {
setImageError(null);
}
const placeholders = toUpload.map((file, index) => ({
const placeholders = toQueue.map((file, index) => ({
id: `${Date.now()}-${index}-${file.name || 'pasted-image'}`,
file,
previewUrl: URL.createObjectURL(file),
url: '',
uploading: true,
sizeBytes: file.size,
uploadedUrl: null,
}));
setPendingImages((prev) => [...prev, ...placeholders]);
setUploadingImage(true);
try {
const uploaded = await Promise.all(toUpload.map((file) => onUploadImage(file)));
const placeholderIds = placeholders.map((item) => item.id);
setPendingImages((prev) => {
const next = prev.map((item) => {
const index = placeholderIds.indexOf(item.id);
if (index < 0 || !uploaded[index]) return item;
return { ...item, url: uploaded[index], uploading: false };
});
const seen = new Set<string>();
return next.filter((item) => {
if (seen.has(item.url)) return false;
seen.add(item.url);
return true;
});
});
} catch (err) {
setPendingImages((prev) => {
const removeIds = new Set(placeholders.map((item) => item.id));
prev.forEach((item) => {
if (removeIds.has(item.id)) revokePendingImage(item);
});
return prev.filter((item) => !removeIds.has(item.id));
});
setImageError(err instanceof Error ? err.message : '图片上传失败');
} finally {
setUploadingImage(false);
}
};
const handleRemoveImage = (idToRemove: string) => {
@@ -365,7 +547,20 @@ export function ChatPanel({
return (
<>
<main className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}>
<main
ref={mainRef}
className={compact ? 'space-chat-panel-body' : `main${showHomeWelcome ? ' main-home' : ''}`}
onScroll={handleMainScroll}
>
{!showHomeWelcome && (historyLoadingMore || historyHasMore || showHistoryFullyLoadedNotice) && (
<div className="chat-history-loader" role="status">
{historyLoadingMore
? `正在加载更早消息… 已加载 ${messages.length}${historyTotal > 0 ? ` / ${historyTotal}` : ''}`
: historyHasMore
? `向上滚动加载更早消息${historyTotal > 0 ? ` · 已加载 ${messages.length} / ${historyTotal}` : ''}`
: `历史消息已全部加载${historyTotal > 0 ? ` · ${messages.length} / ${historyTotal}` : ''}`}
</div>
)}
<MessageList
messages={messages}
streaming={chatState === 'streaming'}
@@ -381,7 +576,6 @@ export function ChatPanel({
<AvatarPicker variant="compact" />
</div>
)}
<div ref={bottomRef} />
</main>
{pendingTool && (
@@ -426,14 +620,29 @@ export function ChatPanel({
{pendingImages.length > 0 && (
<div className="chat-image-attachment-grid">
{pendingImages.map((item, index) => (
<div key={item.id} className="chat-image-attachment-item">
<a href={item.url || item.previewUrl} target="_blank" rel="noreferrer">
<div
key={item.id}
className={`chat-image-attachment-item chat-image-attachment-item-${item.uploadStatus}`}
>
<a href={item.uploadedUrl || item.previewUrl} target="_blank" rel="noreferrer">
<img src={item.previewUrl} alt={`上传图片 ${index + 1}`} className="chat-image-attachment-thumb" />
</a>
{item.uploadStatus === 'uploading' && (
<div className="chat-image-upload-progress" aria-label={`上传进度 ${item.uploadProgress ?? 0}%`}>
<div className="chat-image-upload-progress-fill" style={{ width: `${item.uploadProgress ?? 0}%` }} />
<span>{item.uploadProgress ?? 0}%</span>
</div>
)}
{item.uploadStatus === 'uploaded' && (
<div className="chat-image-upload-complete" aria-label="上传完成">
已完成
</div>
)}
<button
type="button"
className="chat-image-attachment-remove"
aria-label="移除图片"
disabled={item.uploadStatus === 'uploading'}
onClick={() => handleRemoveImage(item.id)}
>
×
+28 -39
View File
@@ -4,7 +4,6 @@ import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import type { CapabilityMap, PortalUser } from '../types';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { getSessionDisplayName } from '../utils/sessions';
import { openAvatarPicker } from '../utils/userAvatar';
import { BalanceRing } from './BalanceRing';
import { HistorySidebar } from './HistorySidebar';
import { TKMindAvatar } from './TKMindAvatar';
@@ -23,6 +22,7 @@ export function ChatView({
onOpenSpace,
onOpenPage,
onOpenAdmin,
onOpenFeedback,
}: {
user?: PortalUser | null;
capabilities?: CapabilityMap;
@@ -32,25 +32,32 @@ export function ChatView({
onOpenSpace?: (target?: { categoryCode?: MindSpaceSaveCategory; pageId?: string }) => void;
onOpenPage?: (pageId: string) => void;
onOpenAdmin?: () => void;
onOpenFeedback?: () => void;
}) {
const {
session,
sessions,
sessionsLoading,
sessionsLoadingMore,
sessionsHasMore,
sessionSearchQuery,
messages,
messageHistoryLoadingMore,
messageHistoryHasMore,
messageHistoryTotal,
chatState,
error,
notice,
pendingTool,
memoryLoading,
submit,
stop,
approveTool,
newSession,
rememberCurrentContext,
refreshProjectMemory,
switchSession,
deleteSession,
loadMoreSessions,
loadOlderMessages,
setSessionSearchQuery,
retryConnect,
dismissNotice,
onSidebarOpen,
@@ -71,11 +78,6 @@ export function ChatView({
}
}, [sidebarOpen, onSidebarOpen]);
const busy =
chatState === 'streaming' ||
chatState === 'loading' ||
chatState === 'connecting' ||
chatState === 'waiting';
const showHomeWelcome = messages.length === 0;
const handleSelectSession = (sessionId: string) => {
@@ -100,18 +102,9 @@ export function ChatView({
label: '新会话',
onClick: () => void handleNewSession(),
},
{
id: 'remember',
label: '记住本轮',
onClick: () => void rememberCurrentContext(),
disabled: busy || memoryLoading || messages.length === 0,
},
{
id: 'refresh-memory',
label: memoryLoading ? '同步中…' : '刷新记忆',
onClick: () => void refreshProjectMemory(),
disabled: busy || memoryLoading,
},
...(onOpenFeedback
? [{ id: 'feedback', label: '反馈与建议', onClick: () => onOpenFeedback() }]
: []),
...(onOpenAdmin
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
: []),
@@ -127,10 +120,15 @@ export function ChatView({
sessions={sessions}
activeSessionId={session?.id}
loading={sessionsLoading}
loadingMore={sessionsLoadingMore}
hasMore={sessionsHasMore}
searchQuery={sessionSearchQuery}
onClose={() => setSidebarOpen(false)}
onSelect={handleSelectSession}
onNew={handleNewSession}
onDelete={(sessionId) => void deleteSession(sessionId)}
onLoadMore={() => void loadMoreSessions()}
onSearchChange={setSessionSearchQuery}
/>
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
@@ -172,24 +170,11 @@ export function ChatView({
我的空间
</button>
)}
<button
type="button"
className="ghost-btn"
disabled={busy || memoryLoading || messages.length === 0}
onClick={() => void rememberCurrentContext()}
title="把最近几条对话保存到 harness 项目知识库"
>
记住本轮
</button>
<button
type="button"
className="ghost-btn"
disabled={busy || memoryLoading}
onClick={() => void refreshProjectMemory()}
title="从 harness 和项目文件重新加载长期记忆"
>
{memoryLoading ? '同步中…' : '刷新记忆'}
</button>
{onOpenFeedback && (
<button type="button" className="ghost-btn" onClick={() => onOpenFeedback()}>
反馈
</button>
)}
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
新会话
</button>
@@ -296,6 +281,9 @@ export function ChatView({
variant="full"
user={user}
messages={messages}
historyLoadingMore={messageHistoryLoadingMore}
historyHasMore={messageHistoryHasMore}
historyTotal={messageHistoryTotal}
chatState={chatState}
pendingTool={pendingTool}
session={session}
@@ -305,6 +293,7 @@ export function ChatView({
void submit(text, undefined, imageUrls, previewImageUrls)
}
onUploadImage={uploadChatImage}
onLoadOlderMessages={loadOlderMessages}
onStop={stop}
onApproveTool={approveTool}
onPageSaved={(result) => {
+189
View File
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { listFeedbackBoard } from '../api/client';
import type { FeedbackSubmission, PortalUser } from '../types';
import {
FEEDBACK_STATUS_LABELS,
FEEDBACK_TYPE_LABELS,
formatFeedbackTime,
} from '../utils/feedbackLabels';
import { FeedbackPageHeader } from './FeedbackPageHeader';
const PAGE_SIZE = 10;
function FeedbackBoardItem({
item,
currentUserId,
onOpen,
}: {
item: FeedbackSubmission;
currentUserId?: string;
onOpen: (id: string) => void;
}) {
const isMine = item.userId === currentUserId;
return (
<li className="feedback-board-item">
<button type="button" className="feedback-board-item-trigger" onClick={() => onOpen(item.id)}>
<div className="feedback-board-item-main">
<div className="feedback-list-item-badges">
<span className={`feedback-badge feedback-badge-type feedback-badge-type-${item.type}`}>
{FEEDBACK_TYPE_LABELS[item.type]}
</span>
<span className={`feedback-badge feedback-badge-status feedback-badge-status-${item.status}`}>
{FEEDBACK_STATUS_LABELS[item.status]}
</span>
{isMine ? <span className="feedback-badge feedback-badge-mine">我的</span> : null}
</div>
<strong className="feedback-board-item-title">{item.title}</strong>
{item.description ? <p className="feedback-board-item-preview">{item.description}</p> : null}
</div>
<div className="feedback-board-item-meta">
<span>{item.submitterDisplayName ?? '用户'}</span>
<time dateTime={new Date(item.createdAt).toISOString()}>{formatFeedbackTime(item.createdAt)}</time>
{(item.imageCount ?? 0) > 0 ? <span>{item.imageCount} 图</span> : null}
<span className="feedback-list-item-chevron" aria-hidden="true">
▸
</span>
</div>
</button>
</li>
);
}
export function FeedbackBoardView({
user,
onLogout,
}: {
user?: PortalUser | null;
onLogout?: () => void;
}) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const page = Math.max(Number(searchParams.get('page')) || 1, 1);
const [items, setItems] = useState<FeedbackSubmission[]>([]);
const [totalPages, setTotalPages] = useState(0);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadPage = useCallback(async (targetPage: number) => {
setLoading(true);
setError(null);
try {
const result = await listFeedbackBoard(targetPage, PAGE_SIZE);
setItems(result.items);
setTotalPages(result.totalPages);
setTotal(result.total);
if (result.totalPages > 0 && targetPage > result.totalPages) {
setSearchParams({ page: String(result.totalPages) }, { replace: true });
}
} catch (err) {
setError(err instanceof Error ? err.message : '反馈列表加载失败');
setItems([]);
} finally {
setLoading(false);
}
}, [setSearchParams]);
useEffect(() => {
void loadPage(page);
}, [loadPage, page]);
const goToPage = (nextPage: number) => {
if (nextPage < 1 || (totalPages > 0 && nextPage > totalPages) || nextPage === page) return;
setSearchParams({ page: String(nextPage) });
};
return (
<div className="feedback-submit-page feedback-board-page">
<FeedbackPageHeader
title="用户反馈"
onBack={() => navigate('/feedback')}
onLogout={onLogout}
/>
<main className="feedback-submit-main feedback-board-main">
<section className="feedback-submit-card feedback-board-card">
<div className="feedback-board-toolbar">
<div className="feedback-board-toolbar-copy">
<p className="feedback-submit-eyebrow">Bug List</p>
<h1>全部用户反馈</h1>
<p className="feedback-submit-desc feedback-board-desc">
共 {total} 条 · 每页 {PAGE_SIZE} 条
</p>
</div>
<div className="feedback-board-toolbar-actions">
<button
type="button"
className="feedback-panel-close"
aria-label="提交 Bug 或需求"
onClick={() => navigate('/feedback')}
>
提交 Bug 或需求
</button>
<button
type="button"
className="feedback-panel-close"
aria-label="关闭反馈"
onClick={() => navigate('/')}
>
关闭
</button>
</div>
</div>
{!loading && total > 0 ? (
<p className="feedback-board-page-indicator">第 {page} / {totalPages || 1} 页</p>
) : null}
<div className="feedback-board-scroll">
{loading ? <p className="feedback-list-muted">加载中…</p> : null}
{error ? <p className="feedback-submit-error">{error}</p> : null}
{!loading && !error && items.length === 0 ? (
<p className="feedback-list-empty">还没有任何用户反馈。</p>
) : null}
{!loading && items.length > 0 ? (
<ul className="feedback-board-list">
{items.map((item) => (
<FeedbackBoardItem
key={item.id}
item={item}
currentUserId={user?.id}
onOpen={(id) => navigate(`/feedback/${id}`)}
/>
))}
</ul>
) : null}
</div>
{totalPages > 1 ? (
<nav className="feedback-pagination" aria-label="反馈分页">
<button
type="button"
className="ghost-btn"
disabled={loading || page <= 1}
onClick={() => goToPage(page - 1)}
>
上一页
</button>
<span className="feedback-pagination-status">
第 {page} / {totalPages} 页
</span>
<button
type="button"
className="ghost-btn"
disabled={loading || page >= totalPages}
onClick={() => goToPage(page + 1)}
>
下一页
</button>
</nav>
) : null}
</section>
</main>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { getFeedbackDetail } from '../api/client';
import type { FeedbackSubmission } from '../types';
import {
FEEDBACK_STATUS_LABELS,
FEEDBACK_TYPE_LABELS,
feedbackImageDataUrl,
formatFeedbackTime,
} from '../utils/feedbackLabels';
import { FeedbackPageHeader } from './FeedbackPageHeader';
export function FeedbackDetailView({
onLogout,
}: {
onLogout?: () => void;
}) {
const navigate = useNavigate();
const { feedbackId } = useParams<{ feedbackId: string }>();
const [item, setItem] = useState<FeedbackSubmission | null>(null);
const [isMine, setIsMine] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!feedbackId) {
setError('反馈不存在');
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
void getFeedbackDetail(feedbackId)
.then((result) => {
if (cancelled) return;
setItem(result.item);
setIsMine(result.isMine);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : '反馈详情加载失败');
setItem(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [feedbackId]);
return (
<div className="feedback-submit-page feedback-board-page">
<FeedbackPageHeader
title="反馈详情"
onBack={() => navigate('/feedback/list')}
onLogout={onLogout}
/>
<main className="feedback-submit-main feedback-board-main">
<section className="feedback-submit-card feedback-board-card feedback-detail-card">
<div className="feedback-board-toolbar">
<div className="feedback-board-toolbar-copy">
{!loading && !error && item ? (
<div className="feedback-list-item-badges">
<span className={`feedback-badge feedback-badge-type feedback-badge-type-${item.type}`}>
{FEEDBACK_TYPE_LABELS[item.type]}
</span>
<span className={`feedback-badge feedback-badge-status feedback-badge-status-${item.status}`}>
{FEEDBACK_STATUS_LABELS[item.status]}
</span>
{isMine ? <span className="feedback-badge feedback-badge-mine">我的</span> : null}
</div>
) : loading ? (
<p className="feedback-list-muted">加载中…</p>
) : null}
</div>
<div className="feedback-board-toolbar-actions">
<button
type="button"
className="feedback-panel-close"
aria-label="返回用户反馈"
onClick={() => navigate('/feedback/list')}
>
返回
</button>
<button
type="button"
className="feedback-panel-close"
aria-label="关闭反馈"
onClick={() => navigate('/')}
>
关闭
</button>
</div>
</div>
<div className="feedback-board-scroll">
{error ? <p className="feedback-submit-error">{error}</p> : null}
{!loading && !error && item ? (
<>
<div className="feedback-detail-header">
<h1>{item.title}</h1>
<p className="feedback-detail-meta">
提交者:{item.submitterDisplayName ?? '用户'}
<span aria-hidden="true"> · </span>
{formatFeedbackTime(item.createdAt)}
<span aria-hidden="true"> · </span>
编号 <code>{item.id.slice(0, 8)}</code>
</p>
</div>
<div className="feedback-detail-section">
<h2>详细描述</h2>
<p className="feedback-detail-description">{item.description || '(无描述)'}</p>
</div>
{isMine && item.contact ? (
<div className="feedback-detail-section">
<h2>联系方式</h2>
<p className="feedback-detail-text">{item.contact}</p>
</div>
) : null}
{item.context?.pagePath ? (
<div className="feedback-detail-section">
<h2>来源页面</h2>
<p className="feedback-detail-text">
<code>{item.context.pagePath}</code>
</p>
</div>
) : null}
{(item.images?.length ?? 0) > 0 ? (
<div className="feedback-detail-section">
<h2>截图</h2>
<div className="feedback-detail-images">
{item.images?.map((image, index) => (
<a
key={`${item.id}-${index}`}
href={feedbackImageDataUrl(image)}
target="_blank"
rel="noopener noreferrer"
className="feedback-detail-image-link"
>
<img
src={feedbackImageDataUrl(image)}
alt={`${item.title} 截图 ${index + 1}`}
className="feedback-detail-image"
/>
</a>
))}
</div>
</div>
) : null}
</>
) : null}
</div>
</section>
</main>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { TKMindAvatar } from './TKMindAvatar';
export function FeedbackPageHeader({
title,
onBack,
onLogout,
}: {
title: string;
onBack: () => void;
onLogout?: () => void;
}) {
return (
<header className="feedback-submit-header">
<button type="button" className="ghost-btn" onClick={onBack}>
返回
</button>
<div className="feedback-submit-header-brand">
<TKMindAvatar size="sm" />
<span>{title}</span>
</div>
{onLogout ? (
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
登出
</button>
) : (
<span aria-hidden="true" />
)}
</header>
);
}
+447
View File
@@ -0,0 +1,447 @@
import { useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { submitFeedback } from '../api/client';
import type { FeedbackImageInput, FeedbackSubmissionType, PortalUser } from '../types';
import {
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
CHAT_IMAGE_MAX_SIDE,
compressImageForUpload,
} from '../utils/imageUpload';
import { FeedbackPageHeader } from './FeedbackPageHeader';
import { VoiceInputButton } from './VoiceInputButton';
const TYPE_OPTIONS: Array<{ value: FeedbackSubmissionType; label: string; hint: string }> = [
{ value: 'bug', label: 'Bug 报告', hint: '功能异常、报错、无法完成操作' },
{ value: 'feature', label: '功能建议', hint: '新能力、体验优化、流程改进' },
{ value: 'other', label: '其他反馈', hint: '账号、计费、文档等问题' },
];
const MAX_IMAGES = 5;
type PendingImage = {
id: string;
previewUrl: string;
file: File;
};
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
const comma = result.indexOf(',');
resolve(comma >= 0 ? result.slice(comma + 1) : result);
};
reader.onerror = () => reject(new Error('图片读取失败'));
reader.readAsDataURL(file);
});
}
function buildFeedbackContext(sessionId?: string | null) {
return {
pageUrl: window.location.href,
pagePath: `${window.location.pathname}${window.location.search}`,
userAgent: navigator.userAgent,
viewport: `${window.innerWidth}x${window.innerHeight}`,
appVersion: import.meta.env.VITE_APP_VERSION ?? undefined,
sessionId: sessionId ?? undefined,
};
}
export function FeedbackSubmitView({
user,
sessionId,
onLogout,
}: {
user?: PortalUser | null;
sessionId?: string | null;
onLogout?: () => void;
}) {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
const descriptionTextRef = useRef('');
const voiceBaseRef = useRef('');
const suppressVoiceUpdateRef = useRef(false);
const [type, setType] = useState<FeedbackSubmissionType>('bug');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [contact, setContact] = useState(user?.email ?? '');
const [images, setImages] = useState<PendingImage[]>([]);
const [imageError, setImageError] = useState<string | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [voiceRecording, setVoiceRecording] = useState(false);
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submittedId, setSubmittedId] = useState<string | null>(null);
const [uploadingImages, setUploadingImages] = useState(false);
descriptionTextRef.current = description;
const selectedType = TYPE_OPTIONS.find((item) => item.value === type) ?? TYPE_OPTIONS[0];
const voiceDisabled = submitting || uploadingImages;
const mergeVoiceText = (spoken: string) => {
const base = voiceBaseRef.current.trimEnd();
const chunk = spoken.trim();
if (!chunk) return base;
return base ? `${base} ${chunk}` : chunk;
};
const handleVoiceStart = () => {
suppressVoiceUpdateRef.current = false;
voiceBaseRef.current = descriptionTextRef.current;
setVoiceNotice(null);
};
const handleVoiceLiveTranscript = (text: string) => {
if (suppressVoiceUpdateRef.current) return;
setDescription(mergeVoiceText(text));
};
const handleVoiceTranscript = (text: string) => {
if (suppressVoiceUpdateRef.current) return;
setDescription(mergeVoiceText(text));
setVoiceNotice('已识别,可编辑后提交');
};
const handleVoiceComplete = () => {
setVoiceNotice('已识别,可编辑后提交');
};
const stopVoiceInput = () => {
suppressVoiceUpdateRef.current = true;
if (voiceRecording) {
setVoiceStopSignal((value) => value + 1);
}
setVoiceRecording(false);
};
const handlePickImages = async (files: FileList | null) => {
if (!files?.length) return;
setImageError(null);
const remaining = MAX_IMAGES - images.length;
if (remaining <= 0) {
setImageError(`最多上传 ${MAX_IMAGES} 张图片`);
return;
}
const selected = Array.from(files).slice(0, remaining);
setUploadingImages(true);
try {
const nextItems: PendingImage[] = [];
for (const file of selected) {
if (!file.type.startsWith('image/')) {
throw new Error('只支持图片文件');
}
const compressed = await compressImageForUpload(file, {
maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
maxDimension: CHAT_IMAGE_MAX_SIDE,
});
nextItems.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
previewUrl: URL.createObjectURL(compressed),
file: compressed,
});
}
setImages((prev) => [...prev, ...nextItems]);
} catch (err) {
setImageError(err instanceof Error ? err.message : '图片处理失败');
} finally {
setUploadingImages(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const removeImage = (id: string) => {
setImages((prev) => {
const target = prev.find((item) => item.id === id);
if (target) URL.revokeObjectURL(target.previewUrl);
return prev.filter((item) => item.id !== id);
});
};
const resetForm = () => {
stopVoiceInput();
setSubmittedId(null);
setTitle('');
setDescription('');
voiceBaseRef.current = '';
suppressVoiceUpdateRef.current = false;
setVoiceNotice(null);
setImages([]);
setType('bug');
setError(null);
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
stopVoiceInput();
const trimmedTitle = title.trim();
const trimmedDescription = description.trim();
if (!trimmedTitle) {
suppressVoiceUpdateRef.current = false;
setError('请填写标题');
return;
}
if (!trimmedDescription) {
suppressVoiceUpdateRef.current = false;
setError('请填写详细描述,或使用语音口述');
descriptionRef.current?.focus();
return;
}
setSubmitting(true);
try {
const imagePayload: FeedbackImageInput[] = [];
for (const item of images) {
imagePayload.push({
filename: item.file.name,
mimeType: item.file.type || 'image/jpeg',
dataBase64: await fileToBase64(item.file),
});
}
const result = await submitFeedback({
type,
title: trimmedTitle,
description: trimmedDescription,
contact: contact.trim() || undefined,
images: imagePayload,
context: buildFeedbackContext(sessionId),
});
setSubmittedId(result.id);
suppressVoiceUpdateRef.current = false;
setVoiceNotice(null);
} catch (err) {
suppressVoiceUpdateRef.current = false;
setError(err instanceof Error ? err.message : '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
};
return (
<div className="feedback-submit-page feedback-board-page">
<FeedbackPageHeader
title={submittedId ? '反馈已收到' : '反馈与建议'}
onBack={() => navigate(-1)}
onLogout={onLogout}
/>
<main className="feedback-submit-main feedback-board-main">
{submittedId ? (
<section className="feedback-submit-card feedback-board-card feedback-submit-success-card">
<div className="feedback-board-toolbar">
<div className="feedback-board-toolbar-copy">
<p className="feedback-submit-eyebrow">提交成功</p>
<h1>感谢你的反馈</h1>
<p className="feedback-submit-desc feedback-board-desc">
我们已收到你的{selectedType.label},编号 <code>{submittedId.slice(0, 8)}</code>。
你可以在「用户反馈」中查看进度。
</p>
</div>
<button
type="button"
className="feedback-panel-close"
aria-label="查看用户反馈"
onClick={() => navigate('/feedback/list')}
>
用户反馈
</button>
</div>
<div className="feedback-submit-actions page-save-actions">
<button type="button" className="page-save-primary" onClick={() => navigate(`/feedback/${submittedId}`)}>
查看详情
</button>
<button type="button" className="ghost-btn" onClick={() => navigate('/feedback/list')}>
用户反馈
</button>
<button type="button" className="ghost-btn" onClick={resetForm}>
继续提交
</button>
</div>
</section>
) : (
<form
className="feedback-submit-card feedback-board-card feedback-submit-form-card"
onSubmit={(event) => void handleSubmit(event)}
>
<div className="feedback-board-toolbar">
<div className="feedback-board-toolbar-copy">
<p className="feedback-submit-eyebrow">帮助我们改进 TKMind</p>
<h1>提交 Bug 或需求</h1>
<p className="feedback-submit-desc feedback-board-desc">
你可以用文字描述、上传截图,或点击麦克风口述问题。我们会自动附带当前页面与设备信息,便于定位问题。
</p>
</div>
<button
type="button"
className="feedback-panel-close"
aria-label="查看用户反馈"
onClick={() => navigate('/feedback/list')}
>
用户反馈
</button>
</div>
<div className="feedback-board-scroll">
<fieldset className="feedback-submit-fieldset">
<legend>反馈类型</legend>
<div className="feedback-type-grid" role="radiogroup" aria-label="反馈类型">
{TYPE_OPTIONS.map((option) => (
<label
key={option.value}
className={`feedback-type-option${type === option.value ? ' is-selected' : ''}`}
>
<input
type="radio"
name="feedback-type"
value={option.value}
checked={type === option.value}
onChange={() => setType(option.value)}
/>
<span className="feedback-type-label">{option.label}</span>
<span className="feedback-type-hint">{option.hint}</span>
</label>
))}
</div>
</fieldset>
<label className="feedback-submit-label">
标题
<input
className="input"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={
type === 'bug'
? '例如:保存页面时按钮无响应'
: type === 'feature'
? '例如:希望支持批量导出'
: '简要概括你的问题或建议'
}
maxLength={120}
required
/>
</label>
<div className="feedback-description-block">
<label className="feedback-submit-label" htmlFor="feedback-description">
详细描述
</label>
{voiceNotice ? (
<div className="feedback-voice-notice" role="status">
{voiceNotice}
</div>
) : null}
<div className="feedback-description-shell">
<textarea
id="feedback-description"
ref={descriptionRef}
className="input feedback-description-input"
rows={5}
value={description}
disabled={voiceDisabled}
readOnly={voiceRecording}
onChange={(event) => setDescription(event.target.value)}
placeholder="请描述复现步骤、期望行为,或你想实现的功能…点击右下角麦克风可语音输入。"
maxLength={5000}
required
/>
<VoiceInputButton
disabled={voiceDisabled}
onVoiceStart={handleVoiceStart}
onLiveTranscript={handleVoiceLiveTranscript}
onTranscript={handleVoiceTranscript}
onVoiceComplete={handleVoiceComplete}
onRecordingChange={setVoiceRecording}
onError={setVoiceNotice}
stopSignal={voiceStopSignal}
/>
</div>
<p className="feedback-description-hint">
{voiceRecording ? '正在聆听,文字会实时显示在输入框中…' : '支持实时听写;识别完成后可继续编辑再提交。'}
</p>
</div>
<div className="feedback-submit-label">
<span>截图(可选,最多 {MAX_IMAGES} 张)</span>
{imageError ? <p className="feedback-submit-inline-error">{imageError}</p> : null}
<div className="feedback-image-grid">
{images.map((item) => (
<div key={item.id} className="feedback-image-item">
<img src={item.previewUrl} alt="反馈截图预览" className="feedback-image-thumb" />
<button
type="button"
className="feedback-image-remove"
aria-label="移除图片"
onClick={() => removeImage(item.id)}
>
×
</button>
</div>
))}
{images.length < MAX_IMAGES ? (
<button
type="button"
className="feedback-image-add"
disabled={uploadingImages || submitting}
onClick={() => fileInputRef.current?.click()}
>
{uploadingImages ? '处理中…' : '+ 添加图片'}
</button>
) : null}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(event) => void handlePickImages(event.target.files)}
/>
</div>
<label className="feedback-submit-label">
联系方式(可选)
<input
className="input"
value={contact}
onChange={(event) => setContact(event.target.value)}
placeholder="邮箱或微信,便于我们回复你"
maxLength={120}
autoComplete="email"
/>
</label>
<p className="feedback-submit-meta">
将自动附带:当前路径 <code>{window.location.pathname}</code>
{sessionId ? <>、会话 ID</> : null}
</p>
{error ? <p className="feedback-submit-error">{error}</p> : null}
</div>
<div className="feedback-submit-actions page-save-actions">
<button type="button" className="ghost-btn" disabled={submitting} onClick={() => navigate(-1)}>
取消
</button>
<button type="submit" className="page-save-primary" disabled={submitting || uploadingImages}>
{submitting ? '提交中…' : '提交反馈'}
</button>
</div>
</form>
)}
</main>
</div>
);
}
+29 -13
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AvatarPicker } from './AvatarPicker';
import { appConfig } from '../config';
import type { Session } from '../types';
import type { SessionSummary } from '../types';
import { getSessionListLabel, groupSessionsByDate } from '../utils/sessions';
function formatSessionTime(iso?: string): string {
@@ -37,13 +36,18 @@ function CloseIcon() {
type HistorySidebarProps = {
open: boolean;
sessions: Session[];
sessions: SessionSummary[];
activeSessionId?: string;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
searchQuery: string;
onClose: () => void;
onSelect: (sessionId: string) => void;
onNew: () => void;
onDelete: (sessionId: string) => void;
onLoadMore: () => void;
onSearchChange: (query: string) => void;
};
export function HistorySidebar({
@@ -51,18 +55,21 @@ export function HistorySidebar({
sessions,
activeSessionId,
loading,
loadingMore,
hasMore,
searchQuery,
onClose,
onSelect,
onNew,
onDelete,
onLoadMore,
onSearchChange,
}: HistorySidebarProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
useEffect(() => {
if (open) {
setVisibleCount(appConfig.sessionPageSize);
setPendingDeleteId(null);
}
}, [open, sessions.length]);
@@ -71,10 +78,10 @@ export function HistorySidebar({
const el = bodyRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (nearBottom && visibleCount < sessions.length) {
setVisibleCount((count) => Math.min(count + appConfig.sessionPageSize, sessions.length));
if (nearBottom && hasMore && !loadingMore && !loading) {
onLoadMore();
}
}, [visibleCount, sessions.length]);
}, [hasMore, loading, loadingMore, onLoadMore]);
const handleDeleteClick = useCallback((sessionId: string) => {
setPendingDeleteId(sessionId);
@@ -94,9 +101,7 @@ export function HistorySidebar({
if (!open) return null;
const visibleSessions = sessions.slice(0, visibleCount);
const sessionGroups = groupSessionsByDate(visibleSessions);
const hasMore = visibleCount < sessions.length;
const sessionGroups = groupSessionsByDate(sessions);
return (
<>
@@ -114,11 +119,21 @@ export function HistorySidebar({
<button type="button" className="sidebar-new" onClick={onNew}>
+ 新对话
</button>
<label className="sidebar-search">
<input
type="search"
className="sidebar-search-input"
value={searchQuery}
placeholder="搜索历史标题"
aria-label="搜索历史对话"
onChange={(event) => onSearchChange(event.target.value)}
/>
</label>
{loading && sessions.length === 0 ? (
<div className="sidebar-empty">加载中…</div>
) : sessions.length === 0 ? (
<div className="sidebar-empty">暂无历史对话</div>
<div className="sidebar-empty">{searchQuery ? '没有找到相关历史标题' : '暂无历史对话'}</div>
) : (
<>
{sessionGroups.map((group) => (
@@ -175,7 +190,8 @@ export function HistorySidebar({
</ul>
</section>
))}
{hasMore && <div className="sidebar-more">向下滚动加载更多…</div>}
{loadingMore && <div className="sidebar-more">加载更多历史…</div>}
{!loadingMore && hasMore && <div className="sidebar-more">向下滚动加载更多…</div>}
</>
)}
</div>
+38 -28
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { useUserAvatar } from '../hooks/useUserAvatar';
import type { Message } from '../types';
import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking } from '../utils/message';
@@ -8,7 +8,6 @@ import { filterText } from '../utils/wordFilter';
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
import { TKMindAvatar } from './TKMindAvatar';
import { UserAvatar } from './UserAvatar';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatWelcomePanel } from './ChatWelcomePanel';
function CopyIcon() {
@@ -133,6 +132,33 @@ function CopyButton({ text }: { text: string }) {
);
}
function PublicLinkCopyButton({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const copy = async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
};
return (
<button
type="button"
className={`msg-public-share-link${copied ? ' is-copied' : ''}`}
onClick={() => void copy()}
title={url}
>
{copied ? '已复制' : '复制链接'}
</button>
);
}
function TimeDivider({ timestamp }: { timestamp: number }) {
return (
<div className="msg-time-divider">
@@ -184,7 +210,6 @@ function MessageRow({
compact?: boolean;
}) {
const [actionsOpen, setActionsOpen] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const rawText = getDisplayText(message);
const text = filterText(rawText);
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
@@ -275,14 +300,8 @@ function MessageRow({
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && sessionId && message.id && (
<button
type="button"
className="msg-open-preview"
onClick={() => setPreviewOpen(true)}
>
打开预览
</button>
{saveActions.previewUrl && (
<PublicLinkCopyButton url={saveActions.previewUrl} />
)}
</>
)}
@@ -301,14 +320,8 @@ function MessageRow({
>
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
</button>
{saveActions.previewUrl && sessionId && message.id && (
<button
type="button"
className="msg-open-preview"
onClick={() => setPreviewOpen(true)}
>
打开预览
</button>
{saveActions.previewUrl && (
<PublicLinkCopyButton url={saveActions.previewUrl} />
)}
</>
)}
@@ -324,14 +337,6 @@ function MessageRow({
title={onAvatarClick ? '点击更换头像' : undefined}
/>
)}
{previewOpen && sessionId && message.id && (
<ChatSharePreviewModal
sessionId={sessionId}
messageId={message.id}
onClose={() => setPreviewOpen(false)}
onSave={onSaveAsPage ? () => onSaveAsPage(message) : undefined}
/>
)}
</div>
);
}
@@ -363,8 +368,13 @@ export function MessageList({
{messages.map((message, index) => {
const prev = index > 0 ? messages[index - 1] : undefined;
const showTime = shouldShowTimestamp(message.created, prev?.created);
const anchorId = message.id ?? `msg-${index}-${message.role}-${message.created}`;
return (
<div key={message.id ?? `msg-${index}`} className="msg-block">
<div
key={message.id ?? `msg-${index}`}
className="msg-block"
data-message-anchor={anchorId}
>
{showTime && <TimeDivider timestamp={message.created} />}
<MessageRow
message={message}
+105 -66
View File
@@ -24,7 +24,7 @@ import {
uploadMindSpaceAsset,
ApiError,
} from '../api/client';
import { compressImageForUpload } from '../utils/imageUpload';
import { MINDSPACE_IMAGE_UPLOAD_MAX_BYTES } from '../utils/imageUpload';
import type {
MindSpace,
MindSpaceAsset,
@@ -63,7 +63,7 @@ import { NotificationCenter } from './NotificationCenter';
const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
oa: '上传文档、表格和资料,让 AI 生成报告与页面',
private: '保存敏感资料,发布前必须完成风险检查与脱敏',
private: '已停用',
public: '管理可公开使用的页面、图片和静态资源',
draft: '继续编辑尚未发布或等待安全检查的页面',
archive: '保存不再活跃但仍需要保留的内容',
@@ -71,13 +71,20 @@ const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
oa: '进入工作区',
private: '进入私人区',
public: '查看发布',
draft: '继续创作',
};
const AGENT_JOBS_PAGE_SIZE = 10;
const RECENT_PAGES_PAGE_SIZE = 6;
function sortPagesByCreatedAt(pages: MindSpacePage[]) {
return [...pages].sort((left, right) => {
const createdDiff = (right.createdAt ?? 0) - (left.createdAt ?? 0);
if (createdDiff !== 0) return createdDiff;
return (right.updatedAt ?? 0) - (left.updatedAt ?? 0);
});
}
const IMAGE_PAGE_SIZE = 10;
const DEFAULT_MAX_UPLOAD_FILE_BYTES = 5 * 1024 * 1024;
const UPLOAD_FILE_EXTENSIONS = [
@@ -282,15 +289,29 @@ function fileExtension(filename: string) {
return dotIndex >= 0 ? filename.slice(dotIndex).toLowerCase() : '';
}
function isUploadImageFile(file: File) {
const extension = fileExtension(file.name);
return (
file.type.startsWith('image/') ||
extension === '.png' ||
extension === '.jpg' ||
extension === '.jpeg' ||
extension === '.webp'
);
}
function validateSelectedUploadFile(file: File, maxBytes: number) {
if (file.size <= 0) return '文件不能为空,请重新选择。';
if (file.size > maxBytes) {
return `文件不能超过 ${formatBytes(maxBytes)},当前为 ${formatBytes(file.size)}。`;
}
const extension = fileExtension(file.name);
if (!UPLOAD_FILE_EXTENSIONS.includes(extension as (typeof UPLOAD_FILE_EXTENSIONS)[number])) {
return `暂不支持 ${extension || '无扩展名'} 文件,请选择支持的资料格式。`;
}
const effectiveMaxBytes = isUploadImageFile(file)
? Math.min(maxBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES)
: maxBytes;
if (file.size > effectiveMaxBytes) {
return `文件不能超过 ${formatBytes(effectiveMaxBytes)},当前为 ${formatBytes(file.size)}。`;
}
return null;
}
@@ -450,6 +471,7 @@ export function MindSpaceView({
initialCategoryCode,
onBack,
onLogout,
onOpenFeedback,
routeSync,
}: {
user: PortalUser;
@@ -458,6 +480,7 @@ export function MindSpaceView({
initialCategoryCode?: MindSpaceSaveCategory | null;
onBack: () => void;
onLogout: () => void;
onOpenFeedback?: () => void;
routeSync?: MindSpaceRouteSync;
}) {
const [space, setSpace] = useState<MindSpace | null>(null);
@@ -503,7 +526,6 @@ export function MindSpaceView({
const [agentJobsPage, setAgentJobsPage] = useState(0);
const [agentJobsLoading, setAgentJobsLoading] = useState(false);
const [allPagesOpen, setAllPagesOpen] = useState(false);
const [recentPagesPage, setRecentPagesPage] = useState(0);
const [exportingPageId, setExportingPageId] = useState<string | null>(null);
const [sharePayload, setSharePayload] = useState<SharePayload | null>(null);
const [previewPageTarget, setPreviewPageTarget] = useState<MindSpacePage | null>(null);
@@ -755,6 +777,7 @@ export function MindSpaceView({
setPendingDeleteId(null);
setSelectedAssetIds([]);
setImagePage(0);
void refreshSpaceQuietly();
routeSync?.pushHome();
};
@@ -1032,14 +1055,9 @@ export function MindSpaceView({
setError(null);
setUploadError(null);
try {
const fileToUpload = selectedFile.type.startsWith('image/')
? await compressImageForUpload(selectedFile, {
maxInputBytes: maxUploadFileBytes,
maxOutputBytes: maxUploadFileBytes,
maxDimension: 1600,
})
: selectedFile;
await uploadMindSpaceAsset(selectedCategory.id, fileToUpload);
await uploadMindSpaceAsset(selectedCategory.id, selectedFile, {
maxImageBytes: MINDSPACE_IMAGE_UPLOAD_MAX_BYTES,
});
setUploadOpen(false);
setSelectedFile(null);
await load();
@@ -1254,7 +1272,8 @@ export function MindSpaceView({
const visibleImageAssets =
assetFilter === 'all' || assetFilter === 'images' ? imageAssets : [];
const imagePaginationEnabled = visibleImageAssets.length > IMAGE_PAGE_SIZE;
const imagePaginationEnabled =
selectedCategory?.code !== 'public' && visibleImageAssets.length > IMAGE_PAGE_SIZE;
const imagePageCount = Math.max(
1,
Math.ceil(visibleImageAssets.length / IMAGE_PAGE_SIZE),
@@ -1266,17 +1285,20 @@ export function MindSpaceView({
safeImagePage * IMAGE_PAGE_SIZE + IMAGE_PAGE_SIZE,
)
: visibleImageAssets;
const recentPagesTotal = Math.max(1, Math.ceil(pages.length / RECENT_PAGES_PAGE_SIZE));
const safeRecentPagesPage = Math.min(recentPagesPage, recentPagesTotal - 1);
const pagedRecentPages = pages.slice(
safeRecentPagesPage * RECENT_PAGES_PAGE_SIZE,
safeRecentPagesPage * RECENT_PAGES_PAGE_SIZE + RECENT_PAGES_PAGE_SIZE,
);
useEffect(() => {
const lastRecentPage = Math.max(0, recentPagesTotal - 1);
setRecentPagesPage((current) => Math.min(current, lastRecentPage));
}, [recentPagesTotal, pages.length]);
const sortedPages = useMemo(() => sortPagesByCreatedAt(pages), [pages]);
const recentPagesPreview = sortedPages.slice(0, RECENT_PAGES_PAGE_SIZE);
const imageAssetsByDate = useMemo(() => {
const groups = new Map<string, MindSpaceAsset[]>();
for (const asset of visibleImageAssets) {
const match = asset.filename.match(/images\/(\d{4}-\d{2}-\d{2})\//);
const dateKey = match?.[1] ?? '其他';
const bucket = groups.get(dateKey) ?? [];
bucket.push(asset);
groups.set(dateKey, bucket);
}
return [...groups.entries()].sort((left, right) => right[0].localeCompare(left[0]));
}, [visibleImageAssets]);
const visibleCardAssets =
assetFilter === 'all'
? [...fileAssets, ...workAssets]
@@ -1675,6 +1697,11 @@ export function MindSpaceView({
) : (
<MindSpaceNotificationCenter />
)}
{!previewMode && onOpenFeedback && (
<button type="button" className="ghost-btn" onClick={onOpenFeedback}>
反馈
</button>
)}
<span>{user.displayName}</span>
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
登出
@@ -2032,7 +2059,7 @@ export function MindSpaceView({
</div>
{pages.length > 0 ? (
<div className="mindspace-feed-grid mindspace-recent-work-grid is-all-pages">
{pages.map((page) => (
{sortedPages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
@@ -2116,7 +2143,7 @@ export function MindSpaceView({
</p>
<h2>{selectedCategory.name}</h2>
</div>
{['oa', 'private', 'public'].includes(selectedCategory.code) && (
{['oa', 'public'].includes(selectedCategory.code) && (
<button
type="button"
className="mindspace-primary"
@@ -2127,12 +2154,6 @@ export function MindSpaceView({
)}
</div>
{selectedCategory.code === 'private' && (
<div className="mindspace-security-note">
私人区默认不可公开。后续生成公开内容时,系统会要求风险扫描和脱敏副本。
</div>
)}
{selectedCategory.code === 'draft' ? (
pages.length > 0 ? (
<>
@@ -2164,7 +2185,7 @@ export function MindSpaceView({
</button>
</div>
<div className="mindspace-feed-grid">
{pages.map((page) => (
{sortedPages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
@@ -2185,7 +2206,7 @@ export function MindSpaceView({
) : (
<>
{(() => {
const categoryPages = pages.filter(
const categoryPages = sortedPages.filter(
(p) => p.categoryCode === selectedCategory.code,
);
return categoryPages.length > 0 ? (
@@ -2275,6 +2296,49 @@ export function MindSpaceView({
<span>{visibleImageAssets.length} 张</span>
</div>
)}
{selectedCategory?.code === 'public' ? (
imageAssetsByDate.map(([dateKey, datedAssets]) => (
<div className="mindspace-image-date-group" key={dateKey}>
<div className="mindspace-section-subheading">
<h3>{dateKey === '其他' ? '其他图片' : dateKey}</h3>
<span>{datedAssets.length} 张</span>
</div>
<div className="mindspace-image-grid">
{datedAssets.map((asset) => (
<article className="mindspace-image-card" key={asset.id}>
<label className="mindspace-asset-select">
<input
type="checkbox"
checked={selectedAssetSet.has(asset.id)}
onChange={() => toggleAssetSelected(asset.id)}
/>
<span>选择</span>
</label>
<button
type="button"
className="mindspace-image-thumb"
onClick={() => setPreviewAssetId(asset.id)}
title={asset.displayName}
>
<img
src={buildAssetImageUrl(asset)}
alt={asset.displayName}
loading="lazy"
/>
</button>
<div className="mindspace-image-meta">
<span className="mindspace-image-name">{asset.displayName}</span>
<span className="mindspace-image-size">
{formatBytes(asset.sizeBytes)}
</span>
</div>
{renderAssetActions(asset)}
</article>
))}
</div>
</div>
))
) : (
<div
className={`mindspace-image-grid${imagePaginationEnabled ? ' is-compact' : ''}`}
>
@@ -2317,6 +2381,7 @@ export function MindSpaceView({
</article>
))}
</div>
)}
{imagePaginationEnabled && (
<div className="mindspace-pagination mindspace-image-pagination">
<button
@@ -2453,7 +2518,7 @@ export function MindSpaceView({
</div>
<div className="mindspace-grid">
{space.categories
.filter((category) => ['oa', 'private', 'public'].includes(category.code))
.filter((category) => ['oa', 'public'].includes(category.code))
.map((category) => (
<article
className={`mindspace-card mindspace-card-${category.code}`}
@@ -2533,7 +2598,7 @@ export function MindSpaceView({
{pages.length > 0 ? (
<>
<div className="mindspace-feed-grid mindspace-recent-work-grid">
{pagedRecentPages.map((page) => (
{recentPagesPreview.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
@@ -2542,33 +2607,6 @@ export function MindSpaceView({
/>
))}
</div>
{pages.length > RECENT_PAGES_PAGE_SIZE ? (
<div className="mindspace-pagination">
<button
type="button"
disabled={safeRecentPagesPage <= 0}
onClick={() =>
setRecentPagesPage((value) => Math.max(0, value - 1))
}
>
上一页
</button>
<span>
第 {safeRecentPagesPage + 1} / {recentPagesTotal} 页
</span>
<button
type="button"
disabled={safeRecentPagesPage >= recentPagesTotal - 1}
onClick={() =>
setRecentPagesPage((value) =>
Math.min(recentPagesTotal - 1, value + 1),
)
}
>
下一页
</button>
</div>
) : null}
</>
) : (
<div className="mindspace-recent-work-empty">
@@ -2612,7 +2650,8 @@ export function MindSpaceView({
</button>
</div>
<p className="mindspace-upload-dialog-desc">
支持 {UPLOAD_FILE_TYPE_LABEL},单文件最大 {formatBytes(maxUploadFileBytes)}。
支持 {UPLOAD_FILE_TYPE_LABEL},文档最大 {formatBytes(maxUploadFileBytes)},图片最大{' '}
{formatBytes(Math.min(maxUploadFileBytes, MINDSPACE_IMAGE_UPLOAD_MAX_BYTES))}。
</p>
<input
type="file"
+6 -70
View File
@@ -19,15 +19,10 @@ const CATEGORY_OPTIONS: Array<{
label: 'OA 工作区',
hint: '与上传文档并列的资料卡片',
},
{
code: 'private',
label: '私人区',
hint: '默认不可公开,发布前需检查',
},
{
code: 'public',
label: '公开区',
hint: '可公开候选,仍需走发布流程',
hint: '图片与可公开候选资源',
},
];
@@ -69,7 +64,6 @@ export function PageSaveDialog({
const [saveError, setSaveError] = useState<string | null>(null);
const [saveNotice, setSaveNotice] = useState<string | null>(null);
const [duplicateResolved, setDuplicateResolved] = useState<'replace' | 'new' | null>(null);
const [privateAcknowledged, setPrivateAcknowledged] = useState(false);
const [thumbnailVersion, setThumbnailVersion] = useState(0);
const [previewScrollLock, setPreviewScrollLock] = useState(false);
const titleRef = useRef('');
@@ -142,15 +136,7 @@ export function PageSaveDialog({
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose, saving]);
useEffect(() => {
setPrivateAcknowledged(false);
}, [categoryCode, analysis?.privacyScan?.findings.length]);
const isStaticHtml = analysis?.contentMode === 'static_html';
const privacyFindings = analysis?.privacyScan.findings ?? [];
const privateBlocked = categoryCode === 'private' && analysis?.privacyScan.allowed === false;
const privateNeedsAck =
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
const existingPage = analysis?.existingPage ?? null;
const showDuplicatePrompt = existingPage !== null && duplicateResolved === null && !saveNotice;
@@ -168,10 +154,6 @@ export function PageSaveDialog({
templateId: isStaticHtml ? 'static-html' : templateId,
categoryCode,
selectedLinkIndex,
acknowledgedFindingIds:
categoryCode === 'private' && privateNeedsAck && privateAcknowledged
? privacyFindings.map((finding) => finding.id)
: undefined,
replacePageId,
});
if (result.kind === 'page') {
@@ -308,61 +290,22 @@ export function PageSaveDialog({
<fieldset className="page-save-targets">
<legend>保存到</legend>
{CATEGORY_OPTIONS.map((option) => {
const disabledPrivate =
option.code === 'private' && analysis.privacyScan.allowed === false;
return (
<label
className={`page-save-target${disabledPrivate ? ' is-disabled' : ''}`}
key={option.code}
>
{CATEGORY_OPTIONS.map((option) => (
<label className="page-save-target" key={option.code}>
<input
type="radio"
name="save-target"
value={option.code}
checked={categoryCode === option.code}
disabled={disabledPrivate}
onChange={() => setCategoryCode(option.code)}
/>
<span>
<strong>{option.label}</strong>
<small>
{disabledPrivate
? '内容含阻断级敏感信息,不能保存到私人区'
: option.hint}
</small>
<small>{option.hint}</small>
</span>
</label>
);
})}
))}
</fieldset>
{categoryCode === 'private' && (
<div className="page-save-private-panel">
<strong>私人区安全说明</strong>
<p>私人区默认不可公开。若后续要发布,系统会要求脱敏副本和二次确认。</p>
{privacyFindings.length > 0 && (
<ul className="page-save-private-findings">
{privacyFindings.map((finding) => (
<li key={finding.id}>
{finding.blocking ? '阻断' : '提醒'} · {finding.label ?? finding.type}(
{finding.occurrenceCount} 处){finding.sampleMasked}
</li>
))}
</ul>
)}
{privateNeedsAck && (
<label className="page-save-private-ack">
<input
type="checkbox"
checked={privateAcknowledged}
onChange={(event) => setPrivateAcknowledged(event.target.checked)}
/>
<span>我已知悉上述敏感信息,确认保存到私人区</span>
</label>
)}
</div>
)}
</div>
{!compact && (
@@ -408,14 +351,7 @@ export function PageSaveDialog({
duplicateResolved === 'replace' && existingPage ? existingPage.id : undefined,
)
}
disabled={
saving ||
analysisLoading ||
!title.trim() ||
Boolean(analysisError) ||
privateBlocked ||
(privateNeedsAck && !privateAcknowledged)
}
disabled={saving || analysisLoading || !title.trim() || Boolean(analysisError)}
>
{saving
? '正在保存…'
+5 -2
View File
@@ -1,4 +1,5 @@
const pageSize = Number(import.meta.env.VITE_SESSION_PAGE_SIZE ?? 25);
const pageSize = Number(import.meta.env.VITE_SESSION_PAGE_SIZE ?? 20);
const messagePageSize = Number(import.meta.env.VITE_SESSION_MESSAGE_PAGE_SIZE ?? 20);
export const appConfig = {
workingDir: import.meta.env.VITE_TKMIND_WORKING_DIR ?? '',
@@ -7,7 +8,9 @@ export const appConfig = {
memoryQuery:
import.meta.env.VITE_TKMIND_MEMORY_QUERY ??
'project architecture conventions current goals decisions risks and recent work',
sessionPageSize: Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 25,
sessionPageSize: Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 20,
sessionMessagePageSize:
Number.isFinite(messagePageSize) && messagePageSize > 0 ? messagePageSize : 20,
sessionMaxCount: 100,
} as const;
+14 -13
View File
@@ -18,9 +18,6 @@ import { buildContextPrefix } from '../utils/mindspaceChatContext';
import { buildUserAddressPrefix } from '../utils/userAddress';
import {
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
CHAT_IMAGE_MAX_SIDE,
compressImageForUpload,
} from '../utils/imageUpload';
import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards';
import {
@@ -110,9 +107,8 @@ export function usePageEditSubChat({
const space = await getMindSpace();
const category =
space.categories.find((item) => item.code === 'private') ??
space.categories.find((item) => item.code === 'oa') ??
space.categories.find((item) => item.code === 'public') ??
space.categories.find((item) => item.code === 'oa') ??
space.categories[0];
if (!category) {
throw new Error('当前空间暂无可用的图片上传分类');
@@ -121,18 +117,23 @@ export function usePageEditSubChat({
return category.id;
}, []);
const uploadChatImage = useCallback(async (file: File): Promise<string> => {
const uploadChatImage = useCallback(async (
file: File,
onProgress?: (progress: number) => void,
): Promise<string> => {
if (!file.type.startsWith('image/')) {
throw new Error('只支持图片文件');
}
const compressed = await compressImageForUpload(file, {
maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
maxDimension: CHAT_IMAGE_MAX_SIDE,
});
const categoryId = await resolveChatImageUploadCategoryId();
const asset = await uploadMindSpaceAsset(categoryId, compressed);
return buildAbsoluteAssetImageUrl({ id: asset.id, updatedAt: asset.updatedAt });
const asset = await uploadMindSpaceAsset(categoryId, file, {
maxImageBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
onProgress,
});
return buildAbsoluteAssetImageUrl({
id: asset.id,
updatedAt: asset.updatedAt,
publicUrl: asset.publicUrl,
});
}, [resolveChatImageUploadCategoryId]);
const processEvent = useCallback((event: SessionEvent, requestId: string) => {
+318 -56
View File
@@ -12,15 +12,16 @@ import {
getMe,
listNotifications,
markNotificationRead,
getSession,
listSessions,
loadSessionDetail,
readConfig,
rememberUserMemory,
rememberProjectContext,
uploadMindSpaceAsset,
resumeSession,
subscribeNotificationEvents,
subscribeSessionEvents,
syncUserMemory,
updateProvider,
} from '../api/client';
import { appConfig } from '../config';
@@ -36,6 +37,7 @@ import type {
MindSpaceChatContext,
PortalUser,
Session,
SessionSummary,
SessionEvent,
ToolConfirmation,
UserNotification,
@@ -45,9 +47,6 @@ import { buildUserAddressPrefix } from '../utils/userAddress';
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
import {
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
CHAT_IMAGE_MAX_SIDE,
compressImageForUpload,
} from '../utils/imageUpload';
import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards';
import {
@@ -61,9 +60,10 @@ import {
pushMessage,
} from '../utils/message';
import {
mergeSessionLists,
appendSessionLists,
prependUnique,
shouldShowNewChatTitle,
toSessionSummary,
touchSession,
} from '../utils/sessions';
@@ -86,6 +86,21 @@ async function waitForAgentRun(runId: string) {
throw new Error('后台任务仍在排队,请稍后刷新会话查看结果');
}
function mergeMessagePages(older: Message[], current: Message[]): Message[] {
if (older.length === 0) return current;
if (current.length === 0) return older;
const seen = new Set<string>();
const merged: Message[] = [];
for (const message of [...older, ...current]) {
const key = message.id ?? `${message.role}:${message.created}:${merged.length}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(message);
}
return merged;
}
function isAmbiguousReplySubmitError(err: unknown) {
if (!(err instanceof ApiError)) return true;
return err.status === 0 || err.status === 409 || err.status >= 500;
@@ -98,15 +113,23 @@ export function useTKMindChat(
grantedSkills?: string[],
) {
const canUseProjectMemory = Boolean(capabilities?.context_memory);
const canUseLongTermMemory = Boolean(capabilities?.memory_store);
const [session, setSession] = useState<Session | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsLoadingMore, setSessionsLoadingMore] = useState(false);
const [sessionsHasMore, setSessionsHasMore] = useState(false);
const [sessionSearchQuery, setSessionSearchQueryState] = useState('');
const [messages, setMessages] = useState<Message[]>([]);
const [messageHistoryLoadingMore, setMessageHistoryLoadingMore] = useState(false);
const [messageHistoryHasMore, setMessageHistoryHasMore] = useState(false);
const [messageHistoryTotal, setMessageHistoryTotal] = useState(0);
const [chatState, setChatState] = useState<ChatState>('loading');
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [pendingTool, setPendingTool] = useState<ToolConfirmation | null>(null);
const [memoryLoading, setMemoryLoading] = useState(false);
const [userMemoryLoading, setUserMemoryLoading] = useState(false);
const [rechargePrompt, setRechargePrompt] = useState(false);
const [rechargeForced, setRechargeForced] = useState(false);
const [subscribePrompt, setSubscribePrompt] = useState(false);
@@ -118,7 +141,12 @@ export function useTKMindChat(
const connectTokenRef = useRef(0);
const messagesRef = useRef<Message[]>([]);
const sessionRef = useRef<Session | null>(null);
const sessionsRef = useRef<Session[]>([]);
const sessionsRef = useRef<SessionSummary[]>([]);
const sessionsOffsetRef = useRef(0);
const sessionSearchQueryRef = useRef('');
const messageHistoryLoadedCountRef = useRef(0);
const messageHistoryTotalRef = useRef(0);
const messageHistoryHasMoreRef = useRef(false);
const rememberedContextRef = useRef<string | null>(null);
const rememberInFlightRef = useRef(false);
const fallbackRetriedRef = useRef(new Set<string>());
@@ -201,6 +229,10 @@ export function useTKMindChat(
messagesRef.current = messages;
}, [messages]);
useEffect(() => {
messageHistoryHasMoreRef.current = messageHistoryHasMore;
}, [messageHistoryHasMore]);
useEffect(() => {
sessionRef.current = session;
}, [session]);
@@ -209,6 +241,10 @@ export function useTKMindChat(
sessionsRef.current = sessions;
}, [sessions]);
useEffect(() => {
sessionSearchQueryRef.current = sessionSearchQuery;
}, [sessionSearchQuery]);
useEffect(() => {
if (!user?.id) return;
let cancelled = false;
@@ -283,6 +319,27 @@ export function useTKMindChat(
}
}, [canUseProjectMemory]);
const formatUserMemoryNotice = useCallback((result: {
analyzed: number;
memories: number;
totalMemories: number;
syncedToSession: boolean;
}) => {
const fragments = [];
if (result.memories > 0) {
fragments.push(`新增 ${result.memories} 条长期记忆`);
} else if (result.analyzed > 0) {
fragments.push('已分析最近对话,暂无新的长期记忆');
} else {
fragments.push('没有新的用户对话可提炼');
}
fragments.push(`当前累计 ${result.totalMemories} 条`);
if (result.syncedToSession) {
fragments.push('已同步到当前会话');
}
return fragments.join(',');
}, []);
const rememberRecentContext = useCallback(
async ({
silent = false,
@@ -359,28 +416,80 @@ export function useTKMindChat(
[canUseProjectMemory, loadProjectMemory],
);
const refreshSessions = useCallback(async () => {
setSessionsLoading(true);
const refreshSessions = useCallback(
async (options?: { preserveExisting?: boolean; query?: string }): Promise<SessionSummary[]> => {
setSessionsLoading(true);
try {
const { items, page } = await listSessions({
limit: appConfig.sessionPageSize,
offset: 0,
query: options?.query ?? sessionSearchQueryRef.current,
});
const merged = options?.preserveExisting ? appendSessionLists(sessionsRef.current, items) : items;
const nextOffset = Number(page.offset ?? 0) + items.length;
const total = Math.max(Number(page.total ?? merged.length), merged.length);
sessionsOffsetRef.current = options?.preserveExisting
? Math.max(sessionsOffsetRef.current, nextOffset)
: nextOffset;
setSessionsHasMore(merged.length < total);
if (options?.query != null) {
sessionSearchQueryRef.current = options.query;
setSessionSearchQueryState(options.query);
}
setSessions(merged);
return items;
} catch (err) {
if (err instanceof ApiError && err.status === 0) {
setError('网络不可用,无法刷新历史列表');
}
return [];
} finally {
setSessionsLoading(false);
}
},
[],
);
const loadMoreSessions = useCallback(async () => {
if (sessionsLoading || sessionsLoadingMore || !sessionsHasMore) return;
setSessionsLoadingMore(true);
try {
const items = await listSessions();
setSessions((prev) => mergeSessionLists(prev, items));
const { items, page } = await listSessions({
limit: appConfig.sessionPageSize,
offset: sessionsOffsetRef.current,
query: sessionSearchQueryRef.current,
});
const merged = appendSessionLists(sessionsRef.current, items);
const nextOffset = Number(page.offset ?? sessionsOffsetRef.current) + items.length;
const total = Math.max(Number(page.total ?? merged.length), merged.length);
sessionsOffsetRef.current = Math.max(sessionsOffsetRef.current, nextOffset);
setSessionsHasMore(merged.length < total);
setSessions(merged);
} catch (err) {
if (err instanceof ApiError && err.status === 0) {
setError('网络不可用,无法刷新历史列表');
setError('网络不可用,无法继续加载历史列表');
}
} finally {
setSessionsLoading(false);
setSessionsLoadingMore(false);
}
}, []);
}, [sessionsHasMore, sessionsLoading, sessionsLoadingMore]);
const setSessionSearchQuery = useCallback((query: string) => {
const nextQuery = query.trim();
sessionSearchQueryRef.current = nextQuery;
setSessionSearchQueryState(nextQuery);
sessionsOffsetRef.current = 0;
setSessionsHasMore(false);
void refreshSessions({ query: nextQuery });
}, [refreshSessions]);
const resolveChatImageUploadCategoryId = useCallback(async () => {
if (chatImageCategoryIdRef.current) return chatImageCategoryIdRef.current;
const space = await getMindSpace();
const category =
space.categories.find((item) => item.code === 'private') ??
space.categories.find((item) => item.code === 'oa') ??
space.categories.find((item) => item.code === 'public') ??
space.categories.find((item) => item.code === 'oa') ??
space.categories[0];
if (!category) {
throw new Error('当前空间暂无可用的图片上传分类');
@@ -390,32 +499,48 @@ export function useTKMindChat(
return category.id;
}, []);
const uploadChatImage = useCallback(async (file: File): Promise<string> => {
const uploadChatImage = useCallback(async (
file: File,
onProgress?: (progress: number) => void,
): Promise<string> => {
if (!file.type.startsWith('image/')) {
throw new Error('只支持图片文件');
}
const compressed = await compressImageForUpload(file, {
maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
maxDimension: CHAT_IMAGE_MAX_SIDE,
});
const categoryId = await resolveChatImageUploadCategoryId();
const asset = await uploadMindSpaceAsset(categoryId, compressed);
const asset = await uploadMindSpaceAsset(categoryId, file, {
maxImageBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
onProgress,
});
return buildAbsoluteAssetImageUrl({
id: asset.id,
updatedAt: asset.updatedAt,
publicUrl: asset.publicUrl,
});
}, [resolveChatImageUploadCategoryId]);
const syncSessionMessages = useCallback(async (sessionId: string) => {
try {
const detail = await loadSessionDetail(sessionId);
setSessions((prev) => prependUnique(prev, detail.session));
const currentLoadedCount = Math.max(
messageHistoryLoadedCountRef.current,
messagesRef.current.length,
appConfig.sessionMessagePageSize,
);
const detail = await loadSessionDetail(sessionId, undefined, {
before: 0,
limit: currentLoadedCount,
});
setSessions((prev) => prependUnique(prev, toSessionSummary(detail.session)));
if (sessionRef.current?.id !== sessionId) return;
messagesRef.current = detail.messages;
messageHistoryLoadedCountRef.current = detail.messages.length;
messageHistoryTotalRef.current = Math.max(Number(detail.page.total ?? detail.messages.length), detail.messages.length);
setMessageHistoryHasMore(detail.messages.length < messageHistoryTotalRef.current);
setMessageHistoryTotal(messageHistoryTotalRef.current);
setMessages(detail.messages);
setSession((current) => (current?.id === sessionId ? detail.session : current));
} catch {
} catch (err) {
console.warn('Session final sync failed:', err);
setNotice('对话已完成,但最终内容同步失败;当前显示可能仍是临时流式结果,请稍后刷新。');
// Keep the optimistic streamed state if the follow-up sync fails.
}
}, []);
@@ -488,6 +613,7 @@ export function useTKMindChat(
setMessages((prev) => {
const next = pushMessage(prev, event.message);
messagesRef.current = next;
messageHistoryLoadedCountRef.current = next.length;
return next;
});
const confirmation = getToolConfirmation(event.message);
@@ -500,10 +626,15 @@ export function useTKMindChat(
return;
}
case 'UpdateConversation': {
const snapshot = normalizeConversationMessages(
let snapshot = normalizeConversationMessages(
event.conversation.filter((m) => m.metadata?.userVisible),
);
const loadedCount = Math.max(messageHistoryLoadedCountRef.current, messagesRef.current.length);
if (messageHistoryHasMoreRef.current && loadedCount > 0 && snapshot.length > loadedCount) {
snapshot = snapshot.slice(-loadedCount);
}
messagesRef.current = mergeConversationSnapshot(messagesRef.current, snapshot);
messageHistoryLoadedCountRef.current = messagesRef.current.length;
setMessages(messagesRef.current);
return;
}
@@ -609,6 +740,12 @@ export function useTKMindChat(
setPendingTool(null);
activeRequestId.current = null;
messagesRef.current = [];
messageHistoryLoadedCountRef.current = 0;
messageHistoryTotalRef.current = 0;
messageHistoryHasMoreRef.current = false;
setMessageHistoryHasMore(false);
setMessageHistoryTotal(0);
setMessageHistoryLoadingMore(false);
setMessages([]);
setChatState('connecting');
}, []);
@@ -639,15 +776,29 @@ export function useTKMindChat(
const hints = knownSession
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
: undefined;
const { session: detail, messages: history } = await loadSessionDetail(sessionId, hints);
const { session: detail, messages: history, page } = await loadSessionDetail(
sessionId,
hints,
{
before: 0,
limit: appConfig.sessionMessagePageSize,
},
);
if (token !== connectTokenRef.current) return;
writeStoredSessionId(userRef.current?.id, sessionId);
setSession({ ...detail, ...(resumed ?? {}), id: sessionId });
messagesRef.current = history;
messageHistoryLoadedCountRef.current = history.length;
messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length);
messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current;
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
setMessageHistoryTotal(messageHistoryTotalRef.current);
setMessages(history);
setChatState('idle');
setSessions((prev) => prependUnique(prev, { ...detail, ...resumed, id: sessionId }));
setSessions((prev) =>
prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })),
);
subscribeToSession(sessionId);
},
@@ -666,6 +817,43 @@ export function useTKMindChat(
}
}, []);
const loadOlderMessages = useCallback(async () => {
const sessionId = sessionRef.current?.id;
if (!sessionId || messageHistoryLoadingMore || !messageHistoryHasMoreRef.current) return;
const knownSession = sessionsRef.current.find((item) => item.id === sessionId);
const hints = knownSession
? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at }
: undefined;
setMessageHistoryLoadingMore(true);
try {
const { messages: olderMessages, page } = await loadSessionDetail(
sessionId,
hints,
{
before: messageHistoryLoadedCountRef.current,
limit: appConfig.sessionMessagePageSize,
},
);
if (sessionRef.current?.id !== sessionId) return;
const merged = mergeMessagePages(olderMessages, messagesRef.current);
messagesRef.current = merged;
messageHistoryLoadedCountRef.current = merged.length;
messageHistoryTotalRef.current = Math.max(Number(page.total ?? merged.length), merged.length);
messageHistoryHasMoreRef.current = merged.length < messageHistoryTotalRef.current;
setMessageHistoryHasMore(messageHistoryHasMoreRef.current);
setMessageHistoryTotal(messageHistoryTotalRef.current);
setMessages(merged);
} catch (err) {
if (err instanceof ApiError && err.status === 0) {
setError('网络不可用,无法继续加载更早消息');
}
} finally {
setMessageHistoryLoadingMore(false);
}
}, [messageHistoryLoadingMore]);
const retryConnect = useCallback(async () => {
const sessionId = session?.id ?? readStoredSessionId(userRef.current?.id);
if (!sessionId) return;
@@ -689,45 +877,54 @@ export function useTKMindChat(
setChatState('loading');
setError(null);
const summaries = await refreshSessions();
const previousSessionId = readStoredSessionId(userRef.current?.id);
let staleSession = false;
let restorableSessionId: string | null = null;
if (previousSessionId) {
try {
const previousSession = await getSession(previousSessionId);
if (shouldShowNewChatTitle(previousSession)) {
try {
await deleteChatSession(previousSessionId);
} catch {
// Best-effort cleanup: a failed delete should not block the next fresh chat.
}
clearStoredSessionId(userRef.current?.id);
} else {
restorableSessionId = previousSessionId;
}
} catch (err) {
if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
clearStoredSessionId(userRef.current?.id);
staleSession = true;
} else {
throw err;
const previousSession = summaries.find((item) => item.id === previousSessionId) ?? null;
if (previousSession && shouldShowNewChatTitle(previousSession)) {
try {
await deleteChatSession(previousSessionId);
} catch {
// Best-effort cleanup: a failed delete should not block the next fresh chat.
}
clearStoredSessionId(userRef.current?.id);
setSessions((prev) => prev.filter((item) => item.id !== previousSessionId));
} else {
restorableSessionId = previousSessionId;
}
}
if (staleSession) {
setNotice('上次会话已失效,已为你新建对话');
}
if (cancelled) return;
if (restorableSessionId) {
await connectSessionRef.current(restorableSessionId, { showLoading: false });
try {
await connectSessionRef.current(restorableSessionId, { showLoading: false });
} catch (err) {
if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
clearStoredSessionId(userRef.current?.id);
staleSession = true;
setSession(null);
messageHistoryLoadedCountRef.current = 0;
messageHistoryTotalRef.current = 0;
messageHistoryHasMoreRef.current = false;
setMessageHistoryHasMore(false);
setMessageHistoryTotal(0);
setMessages([]);
setChatState('idle');
} else {
throw err;
}
}
if (cancelled) return;
} else {
setChatState('idle');
}
void refreshSessions();
if (staleSession) {
setNotice('上次会话已失效,已为你新建对话');
}
} catch (err) {
if (!cancelled) {
if (err instanceof ApiError && err.status === 402) {
@@ -752,7 +949,7 @@ export function useTKMindChat(
useEffect(() => {
const handleVisibility = () => {
if (!document.hidden && session?.id) {
void refreshSessions();
void refreshSessions({ preserveExisting: true });
}
};
document.addEventListener('visibilitychange', handleVisibility);
@@ -767,7 +964,11 @@ export function useTKMindChat(
const token = connectTokenRef.current;
const target = sessions.find((item) => item.id === sessionId);
if (target) {
setSession(target);
setSession({
...target,
working_dir: userRef.current?.workspaceRoot ?? '',
conversation: null,
});
writeStoredSessionId(userRef.current?.id, sessionId);
}
@@ -819,6 +1020,7 @@ export function useTKMindChat(
const requestId = crypto.randomUUID();
activeRequestId.current = requestId;
messagesRef.current = [...messagesRef.current, userMessage];
messageHistoryLoadedCountRef.current = messagesRef.current.length;
setMessages(messagesRef.current);
setChatState('waiting');
setError(null);
@@ -926,6 +1128,12 @@ export function useTKMindChat(
clearStoredSessionId(userRef.current?.id);
activeRequestId.current = null;
messagesRef.current = [];
messageHistoryLoadedCountRef.current = 0;
messageHistoryTotalRef.current = 0;
messageHistoryHasMoreRef.current = false;
setMessageHistoryHasMore(false);
setMessageHistoryTotal(0);
setMessageHistoryLoadingMore(false);
setMessages([]);
setSession(null);
setError(null);
@@ -938,6 +1146,38 @@ export function useTKMindChat(
await loadProjectMemory(session.id, true);
}, [chatState, loadProjectMemory, session]);
const rememberCurrentUserMemory = useCallback(async () => {
if (!session || !canUseLongTermMemory) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
setUserMemoryLoading(true);
try {
const result = await rememberUserMemory(session.id);
setNotice(formatUserMemoryNotice(result));
} catch (err) {
setNotice(`保存长期记忆失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setUserMemoryLoading(false);
}
}, [canUseLongTermMemory, chatState, formatUserMemoryNotice, session]);
const refreshUserMemory = useCallback(async () => {
if (!session || !canUseLongTermMemory) return;
if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return;
setUserMemoryLoading(true);
try {
const result = await syncUserMemory(session.id);
setNotice(
result.syncedToSession
? `长期记忆已同步(累计 ${result.totalMemories} 条)`
: '长期记忆已刷新,但当前会话尚未同步',
);
} catch (err) {
setNotice(`刷新长期记忆失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setUserMemoryLoading(false);
}
}, [canUseLongTermMemory, chatState, session]);
const rememberCurrentContext = useCallback(async () => {
if (
chatState === 'streaming' ||
@@ -949,7 +1189,15 @@ export function useTKMindChat(
}, [chatState, rememberRecentContext]);
const onSidebarOpen = useCallback(() => {
void refreshSessions();
if (sessionSearchQueryRef.current) {
void refreshSessions({ query: sessionSearchQueryRef.current });
return;
}
if (sessionsRef.current.length === 0) {
void refreshSessions();
return;
}
void refreshSessions({ preserveExisting: true });
}, [refreshSessions]);
const deleteSession = useCallback(
@@ -987,21 +1235,35 @@ export function useTKMindChat(
session,
sessions,
sessionsLoading,
sessionsLoadingMore,
sessionsHasMore,
sessionSearchQuery,
messages,
messageHistoryLoadingMore,
messageHistoryHasMore,
messageHistoryTotal,
chatState,
error,
notice,
pendingTool,
memoryLoading,
userMemoryLoading,
canUseProjectMemory,
canUseLongTermMemory,
submit,
stop,
approveTool,
newSession,
rememberCurrentContext,
refreshProjectMemory,
rememberCurrentUserMemory,
refreshUserMemory,
switchSession,
deleteSession,
refreshSessions,
loadMoreSessions,
setSessionSearchQuery,
loadOlderMessages,
retryConnect,
dismissNotice,
onSidebarOpen,
+4 -6
View File
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { transcribeOneShot } from '../voice/asrTransport';
import { mapMicError } from '../voice/audioAnalyser';
import { shouldFallbackToServerAsr, shouldPreferServerAsr } from '../voice/capabilities';
import { shouldFallbackToServerAsr, shouldUseLiveSpeech } from '../voice/capabilities';
import { MicCapture } from '../voice/micCapture';
import { waitForMicRelease } from '../voice/micSession';
import { createLiveSpeechRecognition, isSpeechRecognitionSupported } from '../voice/speechRecognition';
import { createLiveSpeechRecognition } from '../voice/speechRecognition';
import type { VoiceSessionPhase } from '../voice/types';
const MIN_RECORD_MS = 500;
@@ -19,9 +19,7 @@ export function useVoiceSession({
const [phase, setPhase] = useState<VoiceSessionPhase>('idle');
const [text, setText] = useState('');
const [analyser, setAnalyser] = useState<AnalyserNode | null>(null);
const [liveRecognition, setLiveRecognition] = useState(
isSpeechRecognitionSupported() && !shouldPreferServerAsr(),
);
const [liveRecognition, setLiveRecognition] = useState(shouldUseLiveSpeech());
const committedRef = useRef('');
const interimRef = useRef('');
@@ -155,7 +153,7 @@ export function useVoiceSession({
if (sessionRef.current !== sessionId) return;
startedAtRef.current = Date.now();
const useLiveSpeech = isSpeechRecognitionSupported() && !shouldPreferServerAsr();
const useLiveSpeech = shouldUseLiveSpeech();
if (useLiveSpeech) {
const recognition = createLiveSpeechRecognition({
+974 -19
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { useChat } from '../context/ChatProvider';
import type { PortalUser } from '../types';
import { FeedbackBoardView } from '../components/FeedbackBoardView';
import { FeedbackDetailView } from '../components/FeedbackDetailView';
import { FeedbackSubmitView } from '../components/FeedbackSubmitView';
export function FeedbackRoutes({
user,
onLogout,
}: {
user: PortalUser | null;
onLogout: () => void;
}) {
const { session } = useChat();
return (
<Routes>
<Route
index
element={<FeedbackSubmitView user={user} sessionId={session?.id} onLogout={onLogout} />}
/>
<Route path="list" element={<FeedbackBoardView user={user} onLogout={onLogout} />} />
<Route path=":feedbackId" element={<FeedbackDetailView onLogout={onLogout} />} />
<Route path="*" element={<Navigate to="/feedback" replace />} />
</Routes>
);
}
+1
View File
@@ -30,6 +30,7 @@ export function MindSpaceRoute({
initialCategoryCode={categoryCode}
onBack={() => navigate('/')}
onLogout={onLogout}
onOpenFeedback={() => navigate('/feedback')}
routeSync={{
pushHome: () => navigate('/space'),
pushCategory: (code) => navigate(`/space?category=${code}`),
+75 -4
View File
@@ -107,20 +107,41 @@ export type SessionEvent =
| { type: 'Ping' }
| { type: 'ActiveRequests'; request_ids: string[] };
export type Session = {
export type SessionSummary = {
id: string;
name: string;
working_dir: string;
message_count: number;
created_at?: string;
updated_at?: string;
user_set_name?: boolean;
recipe?: { title?: string | null } | null;
};
export type Session = SessionSummary & {
working_dir: string;
conversation?: Message[] | null;
conversation_page?: SessionConversationPage;
};
export type SessionListPage = {
total?: number;
offset?: number;
limit?: number;
has_more?: boolean;
query?: string;
};
export type SessionConversationPage = {
total?: number;
before?: number;
limit?: number;
returned?: number;
has_more_before?: boolean;
};
export type SessionListResponse = {
sessions: Session[];
sessions: SessionSummary[];
page?: SessionListPage;
};
export type HarnessBootstrapResponse = {
@@ -129,6 +150,14 @@ export type HarnessBootstrapResponse = {
refreshed: boolean;
};
export type UserMemorySyncResponse = {
ok: true;
analyzed: number;
memories: number;
totalMemories: number;
syncedToSession: boolean;
};
export type ChatState = 'idle' | 'loading' | 'connecting' | 'streaming' | 'waiting' | 'error';
export type ToolConfirmation = {
@@ -232,6 +261,47 @@ export type PortalUser = {
publishSkillName?: string;
};
export type FeedbackSubmissionType = 'bug' | 'feature' | 'other';
export type FeedbackImageInput = {
filename: string;
mimeType: string;
dataBase64: string;
};
export type FeedbackContextInput = {
pageUrl?: string;
pagePath?: string;
userAgent?: string;
viewport?: string;
sessionId?: string;
appVersion?: string;
};
export type FeedbackSubmission = {
id: string;
userId?: string;
type: FeedbackSubmissionType;
title: string;
description?: string;
contact?: string | null;
status: 'pending' | 'reviewing' | 'resolved' | 'closed';
images?: FeedbackImageInput[];
imageCount?: number;
context?: FeedbackContextInput;
submitterDisplayName?: string | null;
createdAt: number;
updatedAt?: number;
};
export type FeedbackBoardPage = {
items: FeedbackSubmission[];
page: number;
pageSize: number;
total: number;
totalPages: number;
};
export type MindSpaceQuota = {
quotaBytes: number;
usedBytes: number;
@@ -629,7 +699,8 @@ export type PolicyDefinition = {
export type AuthStatus = {
authenticated: boolean;
mode?: 'user' | 'legacy' | 'none';
mode?: 'user' | 'legacy' | 'none' | 'unavailable';
message?: string;
user?: PortalUser | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
+28
View File
@@ -0,0 +1,28 @@
import type { FeedbackSubmission, FeedbackSubmissionType } from '../types';
export const FEEDBACK_TYPE_LABELS: Record<FeedbackSubmissionType, string> = {
bug: 'Bug',
feature: '需求',
other: '其他',
};
export const FEEDBACK_STATUS_LABELS: Record<FeedbackSubmission['status'], string> = {
pending: '待处理',
reviewing: '处理中',
resolved: '已解决',
closed: '已关闭',
};
export function formatFeedbackTime(timestamp: number) {
return new Date(timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function feedbackImageDataUrl(image: { mimeType: string; dataBase64: string }) {
return `data:${image.mimeType || 'image/jpeg'};base64,${image.dataBase64}`;
}
+4 -1
View File
@@ -1,5 +1,8 @@
export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 8 * 1024 * 1024;
export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 4 * 1024 * 1024;
export const CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES = 1.5 * 1024 * 1024;
export const MINDSPACE_IMAGE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024;
export const CHAT_IMAGE_UPLOAD_MAX_COUNT = 10;
export const CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
export const CHAT_IMAGE_MAX_SIDE = 1920;
const MAX_PIXELS = 2_500_000;
const ACCEPTED_IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']);
+22 -11
View File
@@ -3,6 +3,9 @@ import { mergeMessageContent } from '../../message-stream.mjs';
import { stripUserAddressPrefix } from './userAddress';
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
const IMAGE_URL_LINES_RE = /\n*\[图片\d+]: [^\n]+/g;
const USER_IDENTITY_BLOCK_RE = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/;
const TKMIND_VISION_NOTE_RE = /\n*【TKMind 图片分析结果[\s\S]*$/;
function normalizeImageUrl(url: string): string {
const value = String(url ?? '').trim();
@@ -32,6 +35,14 @@ function formatImageUrlsForAgentText(urls: string[]): string {
.join('\n');
}
function stripImageUrlLines(text: string): string {
return String(text ?? '')
.replace(USER_IDENTITY_BLOCK_RE, '')
.replace(TKMIND_VISION_NOTE_RE, '')
.replace(IMAGE_URL_LINES_RE, '')
.trim();
}
function parseImageUrlsFromText(text: string): string[] {
const urls: string[] = [];
for (const line of text.split('\n')) {
@@ -71,12 +82,12 @@ export function normalizeUserMessageForApi(message: Message): Message {
const imageUrls = getImageUrls(message);
const displayText =
message.metadata.displayText ??
message.content
.filter((item): item is Extract<MessageContent, { type: 'text' }> => item.type === 'text')
.map((item) => item.text)
.join('\n')
.replace(/\n*\[图片\d+]: [^\n]+/g, '')
.trim();
stripImageUrlLines(
message.content
.filter((item): item is Extract<MessageContent, { type: 'text' }> => item.type === 'text')
.map((item) => item.text)
.join('\n'),
);
const textOnly = message.content
.filter((item): item is Extract<MessageContent, { type: 'text' }> => item.type === 'text')
@@ -89,7 +100,7 @@ export function normalizeUserMessageForApi(message: Message): Message {
}
const imageText = formatImageUrlsForAgentText(imageUrls);
const agentText = [textOnly.replace(/\n*\[图片\d+]: [^\n]+/g, '').trim(), imageText]
const agentText = [stripImageUrlLines(textOnly), imageText]
.filter(Boolean)
.join('\n\n');
@@ -108,7 +119,7 @@ export function buildUserMessage(
text: string,
options?: { agentText?: string; displayText?: string; imageUrls?: string[]; previewImageUrls?: string[] },
): Message {
const displayText = options?.displayText ?? text;
const displayText = stripImageUrlLines(options?.displayText ?? text);
const imageUrls = (options?.imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim());
const previewImageUrls = (options?.previewImageUrls ?? []).filter(
(value) => typeof value === 'string' && value.trim(),
@@ -160,12 +171,12 @@ export function mergeConversationSnapshot(current: Message[], incoming: Message[
export function getDisplayText(message: Message): string {
if ('displayText' in message.metadata) {
return message.metadata.displayText ?? '';
return stripImageUrlLines(message.metadata.displayText ?? '');
}
const systemText = getSystemNotificationText(message);
if (systemText) return systemText;
const visible = getVisibleText(message);
return message.role === 'user' ? stripUserAddressPrefix(visible) : visible;
return message.role === 'user' ? stripImageUrlLines(stripUserAddressPrefix(visible)) : visible;
}
export function pushMessage(messages: Message[], incoming: Message): Message[] {
@@ -187,7 +198,7 @@ export function getVisibleText(message: Message): string {
.filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
.map((c) => c.text)
.join('');
return text.replace(/\n*\[图片\d+]: [^\n]+/g, '').trim();
return stripImageUrlLines(text);
}
export function getImageUrls(message: Message): string[] {
+44 -17
View File
@@ -1,5 +1,4 @@
import { appConfig } from '../config';
import type { Session } from '../types';
import type { Session, SessionSummary } from '../types';
export const DEFAULT_CHAT_TITLE = 'New Chat';
@@ -12,28 +11,42 @@ function isDefaultSessionName(name: string): boolean {
return !trimmed || trimmed === DEFAULT_CHAT_TITLE || trimmed === '新对话' || isGeneratedSessionName(trimmed);
}
export function shouldShowNewChatTitle(session: Session): boolean {
export function shouldShowNewChatTitle(session: SessionSummary): boolean {
if (session.recipe) return false;
return !session.user_set_name && session.message_count === 0 && isDefaultSessionName(session.name);
}
export function sortAndTrim(sessions: Session[]): Session[] {
export function sortAndTrim<T extends SessionSummary>(sessions: T[]): T[] {
return [...sessions]
.sort((a, b) => {
const aTime = new Date(a.updated_at ?? a.created_at ?? 0).getTime();
const bTime = new Date(b.updated_at ?? b.created_at ?? 0).getTime();
return bTime - aTime;
})
.slice(0, appConfig.sessionMaxCount);
});
}
export function hasMeaningfulSessionTitle(session: Session): boolean {
export function hasMeaningfulSessionTitle(session: SessionSummary): boolean {
if (session.user_set_name) return Boolean(session.name.trim());
if (session.recipe?.title?.trim()) return true;
return !isDefaultSessionName(session.name);
}
export function mergeSessionRecord(existing: Session | undefined, incoming: Session): Session {
export function toSessionSummary(session: Session | SessionSummary): SessionSummary {
return {
id: session.id,
name: session.name,
message_count: session.message_count,
created_at: session.created_at,
updated_at: session.updated_at,
user_set_name: session.user_set_name,
recipe: session.recipe ?? null,
};
}
export function mergeSessionRecord(
existing: SessionSummary | undefined,
incoming: SessionSummary,
): SessionSummary {
if (!existing) return incoming;
const keepExistingTitle =
@@ -50,19 +63,33 @@ export function mergeSessionRecord(existing: Session | undefined, incoming: Sess
};
}
export function mergeSessionLists(prev: Session[], incoming: Session[]): Session[] {
export function mergeSessionLists(prev: SessionSummary[], incoming: SessionSummary[]): SessionSummary[] {
const previousById = new Map(prev.map((item) => [item.id, item]));
return sortAndTrim(incoming.map((item) => mergeSessionRecord(previousById.get(item.id), item)));
}
export function prependUnique(prev: Session[], session: Session): Session[] {
export function appendSessionLists(prev: SessionSummary[], incoming: SessionSummary[]): SessionSummary[] {
const previousById = new Map(prev.map((item) => [item.id, item]));
const mergedIncoming = incoming.map((item) => mergeSessionRecord(previousById.get(item.id), item));
const incomingIds = new Set(mergedIncoming.map((item) => item.id));
return sortAndTrim([
...prev.filter((item) => !incomingIds.has(item.id)),
...mergedIncoming,
]);
}
export function prependUnique(prev: SessionSummary[], session: SessionSummary): SessionSummary[] {
return sortAndTrim([
mergeSessionRecord(prev.find((s) => s.id === session.id), session),
...prev.filter((s) => s.id !== session.id),
]);
}
export function touchSession(sessions: Session[], sessionId: string, messageDelta = 0): Session[] {
export function touchSession(
sessions: SessionSummary[],
sessionId: string,
messageDelta = 0,
): SessionSummary[] {
const now = new Date().toISOString();
return sortAndTrim(
sessions.map((s) =>
@@ -77,7 +104,7 @@ export function touchSession(sessions: Session[], sessionId: string, messageDelt
);
}
export function getSessionDisplayName(session: Session): string {
export function getSessionDisplayName(session: SessionSummary): string {
if (session.user_set_name) return session.name;
if (session.recipe?.title) return session.recipe.title;
if (shouldShowNewChatTitle(session)) return DEFAULT_CHAT_TITLE;
@@ -92,7 +119,7 @@ function sameLocalDay(a: Date, b: Date): boolean {
);
}
export function getSessionDateKey(session: Session): string {
export function getSessionDateKey(session: SessionSummary): string {
const iso = session.updated_at ?? session.created_at;
if (!iso) return '';
const date = new Date(iso);
@@ -100,7 +127,7 @@ export function getSessionDateKey(session: Session): string {
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}
export function formatSessionDateLabel(session: Session): string {
export function formatSessionDateLabel(session: SessionSummary): string {
const iso = session.updated_at ?? session.created_at;
if (!iso) return '';
const date = new Date(iso);
@@ -123,10 +150,10 @@ export function formatSessionDateLabel(session: Session): string {
export type SessionDateGroup = {
dateKey: string;
label: string;
sessions: Session[];
sessions: SessionSummary[];
};
export function groupSessionsByDate(sessions: Session[]): SessionDateGroup[] {
export function groupSessionsByDate(sessions: SessionSummary[]): SessionDateGroup[] {
const groups: SessionDateGroup[] = [];
for (const session of sessions) {
const dateKey = getSessionDateKey(session);
@@ -143,7 +170,7 @@ export function groupSessionsByDate(sessions: Session[]): SessionDateGroup[] {
const DEFAULT_SESSION_NAMES = new Set([DEFAULT_CHAT_TITLE, '新对话']);
export function getSessionListLabel(session: Session): string {
export function getSessionListLabel(session: SessionSummary): string {
const displayName = getSessionDisplayName(session).trim();
if (displayName && !DEFAULT_SESSION_NAMES.has(displayName) && !isGeneratedSessionName(displayName)) {
return displayName;
+14 -4
View File
@@ -2,16 +2,26 @@ import { isWechatBrowser } from '../utils/wechat';
import { MicCapture } from './micCapture';
import { isSpeechRecognitionSupported } from './speechRecognition';
/** 微信内置浏览器 Web Speech 不可用,直接走服务端 ASR。 */
/**
* 优先服务端 ASR(录音后识别):
* - 微信内 Web Speech 不可用;
* - 普通浏览器若支持麦克风,也走自有 ASR(Web Speech 依赖 Google,国内常无结果)。
*/
export function shouldPreferServerAsr(): boolean {
return isWechatBrowser();
if (isWechatBrowser()) return true;
return MicCapture.isSupported();
}
/** Web Speech 出现这些错误时可改用服务端 ASR(录音 + 完成识别)。 */
/** 仍尝试 Web Speech 时,出现这些错误可降级为服务端 ASR。 */
export function shouldFallbackToServerAsr(errorMessage: string): boolean {
if (shouldPreferServerAsr()) return false;
if (!MicCapture.isSupported()) return false;
return /network|service-not-allowed|语音识别失败|无法启动语音识别/i.test(errorMessage);
return /network|网络异常|service-not-allowed|语音识别失败|无法启动语音识别/i.test(errorMessage);
}
/** 是否使用浏览器 Web Speech 实时听写(仅在不走服务端 ASR 且 API 可用时)。 */
export function shouldUseLiveSpeech(): boolean {
return isSpeechRecognitionSupported() && !shouldPreferServerAsr();
}
/** Whether voice input may work in this browser (mic and/or live speech). */