feat(feedback): 新增用户反馈提交、分页列表与语音描述
支持 Bug/需求提交、截图与聊天同款语音输入,全站反馈分页浏览与详情页,并从聊天与空间页提供入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+104
-10
@@ -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';
|
||||
@@ -214,7 +223,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;
|
||||
@@ -272,7 +284,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;
|
||||
@@ -310,6 +325,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;
|
||||
@@ -380,8 +409,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 {
|
||||
@@ -416,6 +448,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}`);
|
||||
@@ -521,11 +600,13 @@ export async function listMindSpaceAssets(
|
||||
export async function uploadMindSpaceAsset(
|
||||
categoryId: string,
|
||||
file: File,
|
||||
options: { maxImageBytes?: number } = {},
|
||||
): 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 上传上限。`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2003,9 +2084,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 = '') {
|
||||
@@ -2023,19 +2113,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 sendReply(
|
||||
|
||||
Reference in New Issue
Block a user