Skip to content

Commit 9eb7394

Browse files
feat: Add AI writing assistant, subscription system, inline editing, and bug fixes
AI Writing (Stage 8): - Phase 1: Basic AI commands (continue/polish/translate/summary/longer/shorter/custom) with SSE streaming - Phase 2: Copilot inline autocomplete (ghost text + Tab accept + 800ms debounce) - Phase 3: Document Q&A chat sidebar (multi-turn + document context + floating/sidebar modes) - AI inline panel: appears below selected text with input + preset commands + streaming results + action buttons - Deep thinking mode: toggle button, thinking process shown in collapsible block - Admin AI settings page: configure Provider/Key/Model/daily limit with restore-to-default - LLM Provider abstraction: OpenAI-compatible interface supporting MiniMax/DeepSeek AI Subscription: - Three tiers: Basic (30/day), VIP (100/day), Max (500/day) with yearly doubling - Application-approval workflow with admin management page - AiSubscriptionGuard gates completion/chat endpoints (SuperAdmin bypasses) - Plan-aware daily limits and model overrides - Auto-expiry with 7-day warning notifications - User subscription page with plan cards and request history Editor Enhancements: - Combined text bubble menu: AI creation + formatting tools with pluginKey separation - Image bubble menu: OCR text extraction, style, replace, delete - Toolbar overflow: dynamic detection with "more" dropdown - Markdown rendering in AI responses with syntax-highlighted code blocks Bug Fixes & Polish: - Fix getCdnUrl missing in mention-list.tsx and online-users.tsx - Fix ai-subscriptions page unsafe non-null assertions - Remove login page console.log leak - Fix LoadingScreen z-index (z-50 → z-[9999]) to cover PublicHeader - Fix BubbleMenu pluginKey conflicts between text and image menus Plan Updates: - README.md: Add Stage 8/9, update milestones, expand feature list - stage-7: Mark as fully completed - stage-8: Add implementation summary with completion status Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bbc0f5e commit 9eb7394

60 files changed

Lines changed: 5882 additions & 90 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"fastify": "^5.7.4",
4949
"fastify-multer": "^2.0.3",
5050
"minio": "^8.0.6",
51+
"openai": "^6.32.0",
5152
"passport": "^0.7.0",
5253
"passport-jwt": "^4.0.1",
5354
"passport-local": "^1.0.0",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
-- DropIndex
2+
DROP INDEX "idx_documents_search_vector";
3+
4+
-- CreateTable
5+
CREATE TABLE "ai_config" (
6+
"id" TEXT NOT NULL DEFAULT 'singleton',
7+
"provider" TEXT,
8+
"apiKey" TEXT,
9+
"baseUrl" TEXT,
10+
"model" TEXT,
11+
"dailyLimit" INTEGER,
12+
"updatedAt" TIMESTAMP(3) NOT NULL,
13+
14+
CONSTRAINT "ai_config_pkey" PRIMARY KEY ("id")
15+
);
16+
17+
-- CreateTable
18+
CREATE TABLE "ai_usage_logs" (
19+
"id" TEXT NOT NULL,
20+
"userId" TEXT NOT NULL,
21+
"command" TEXT NOT NULL,
22+
"model" TEXT NOT NULL,
23+
"inputTokens" INTEGER NOT NULL DEFAULT 0,
24+
"outputTokens" INTEGER NOT NULL DEFAULT 0,
25+
"latencyMs" INTEGER NOT NULL DEFAULT 0,
26+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
27+
28+
CONSTRAINT "ai_usage_logs_pkey" PRIMARY KEY ("id")
29+
);
30+
31+
-- CreateIndex
32+
CREATE INDEX "ai_usage_logs_userId_createdAt_idx" ON "ai_usage_logs"("userId", "createdAt");
33+
34+
-- AddForeignKey
35+
ALTER TABLE "ai_usage_logs" ADD CONSTRAINT "ai_usage_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
-- CreateEnum
2+
CREATE TYPE "AiPlan" AS ENUM ('BASIC', 'VIP', 'MAX');
3+
4+
-- CreateEnum
5+
CREATE TYPE "AiBillingPeriod" AS ENUM ('MONTHLY', 'YEARLY');
6+
7+
-- CreateEnum
8+
CREATE TYPE "AiSubStatus" AS ENUM ('ACTIVE', 'EXPIRED', 'CANCELLED');
9+
10+
-- CreateEnum
11+
CREATE TYPE "AiRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
12+
13+
-- AlterEnum
14+
-- This migration adds more than one value to an enum.
15+
-- With PostgreSQL versions 11 and earlier, this is not possible
16+
-- in a single migration. This can be worked around by creating
17+
-- multiple migrations, each migration adding only one value to
18+
-- the enum.
19+
20+
21+
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_APPROVED';
22+
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_REJECTED';
23+
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_EXPIRING';
24+
ALTER TYPE "NotificationType" ADD VALUE 'SUBSCRIPTION_EXPIRED';
25+
26+
-- CreateTable
27+
CREATE TABLE "ai_subscriptions" (
28+
"id" TEXT NOT NULL,
29+
"userId" TEXT NOT NULL,
30+
"plan" "AiPlan" NOT NULL,
31+
"billingPeriod" "AiBillingPeriod" NOT NULL,
32+
"status" "AiSubStatus" NOT NULL DEFAULT 'ACTIVE',
33+
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
34+
"endDate" TIMESTAMP(3) NOT NULL,
35+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
36+
"updatedAt" TIMESTAMP(3) NOT NULL,
37+
38+
CONSTRAINT "ai_subscriptions_pkey" PRIMARY KEY ("id")
39+
);
40+
41+
-- CreateTable
42+
CREATE TABLE "ai_subscription_requests" (
43+
"id" TEXT NOT NULL,
44+
"userId" TEXT NOT NULL,
45+
"plan" "AiPlan" NOT NULL,
46+
"billingPeriod" "AiBillingPeriod" NOT NULL,
47+
"status" "AiRequestStatus" NOT NULL DEFAULT 'PENDING',
48+
"reason" TEXT,
49+
"rejectReason" TEXT,
50+
"reviewedBy" TEXT,
51+
"reviewedAt" TIMESTAMP(3),
52+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
53+
"updatedAt" TIMESTAMP(3) NOT NULL,
54+
55+
CONSTRAINT "ai_subscription_requests_pkey" PRIMARY KEY ("id")
56+
);
57+
58+
-- CreateIndex
59+
CREATE UNIQUE INDEX "ai_subscriptions_userId_key" ON "ai_subscriptions"("userId");
60+
61+
-- CreateIndex
62+
CREATE INDEX "ai_subscriptions_userId_status_idx" ON "ai_subscriptions"("userId", "status");
63+
64+
-- CreateIndex
65+
CREATE INDEX "ai_subscriptions_endDate_idx" ON "ai_subscriptions"("endDate");
66+
67+
-- CreateIndex
68+
CREATE INDEX "ai_subscription_requests_status_createdAt_idx" ON "ai_subscription_requests"("status", "createdAt" DESC);
69+
70+
-- CreateIndex
71+
CREATE INDEX "ai_subscription_requests_userId_createdAt_idx" ON "ai_subscription_requests"("userId", "createdAt" DESC);
72+
73+
-- AddForeignKey
74+
ALTER TABLE "ai_subscriptions" ADD CONSTRAINT "ai_subscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
75+
76+
-- AddForeignKey
77+
ALTER TABLE "ai_subscription_requests" ADD CONSTRAINT "ai_subscription_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
-- This is an empty migration.

apps/api/prisma/schema.prisma

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ model User {
3535
notifications Notification[] @relation("UserNotifications")
3636
notificationPreference NotificationPreference? @relation("UserNotificationPreference")
3737
documentPermissions DocumentPermission[]
38+
aiUsageLogs AiUsageLog[]
39+
aiSubscription AiSubscription? @relation("UserAiSubscription")
40+
aiSubscriptionRequests AiSubscriptionRequest[] @relation("UserAiSubscriptionRequests")
3841
3942
@@map("users")
4043
}
@@ -369,6 +372,10 @@ enum NotificationType {
369372
DOCUMENT_UPDATED
370373
SPACE_DELETED
371374
SYSTEM
375+
SUBSCRIPTION_APPROVED
376+
SUBSCRIPTION_REJECTED
377+
SUBSCRIPTION_EXPIRING
378+
SUBSCRIPTION_EXPIRED
372379
}
373380

374381
// ==================== 通知偏好模型 ====================
@@ -396,3 +403,97 @@ model DocumentFavorite {
396403
@@index([userId, createdAt(sort: Desc)])
397404
@@map("document_favorites")
398405
}
406+
407+
// ==================== AI 配置模型(单例) ====================
408+
model AiConfig {
409+
id String @id @default("singleton")
410+
provider String? // 覆盖 AI_PROVIDER 环境变量
411+
apiKey String? // 覆盖 AI_API_KEY(加密存储)
412+
baseUrl String? // 覆盖 AI_BASE_URL
413+
model String? // 覆盖 AI_MODEL
414+
dailyLimit Int? // 覆盖 AI_DAILY_LIMIT
415+
updatedAt DateTime @updatedAt
416+
417+
@@map("ai_config")
418+
}
419+
420+
// ==================== AI 用量日志 ====================
421+
model AiUsageLog {
422+
id String @id @default(cuid())
423+
userId String
424+
command String // 'continue' | 'polish' | 'translate' | 'summary' | 'custom'
425+
model String // 使用的模型名称
426+
inputTokens Int @default(0)
427+
outputTokens Int @default(0)
428+
latencyMs Int @default(0)
429+
createdAt DateTime @default(now())
430+
431+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
432+
433+
@@index([userId, createdAt])
434+
@@map("ai_usage_logs")
435+
}
436+
437+
// ==================== AI 订阅枚举 ====================
438+
enum AiPlan {
439+
BASIC
440+
VIP
441+
MAX
442+
}
443+
444+
enum AiBillingPeriod {
445+
MONTHLY
446+
YEARLY
447+
}
448+
449+
enum AiSubStatus {
450+
ACTIVE
451+
EXPIRED
452+
CANCELLED
453+
}
454+
455+
enum AiRequestStatus {
456+
PENDING
457+
APPROVED
458+
REJECTED
459+
}
460+
461+
// ==================== AI 订阅模型 ====================
462+
model AiSubscription {
463+
id String @id @default(cuid())
464+
userId String @unique
465+
plan AiPlan
466+
billingPeriod AiBillingPeriod
467+
status AiSubStatus @default(ACTIVE)
468+
startDate DateTime @default(now())
469+
endDate DateTime
470+
createdAt DateTime @default(now())
471+
updatedAt DateTime @updatedAt
472+
473+
user User @relation("UserAiSubscription", fields: [userId], references: [id], onDelete: Cascade)
474+
475+
@@index([userId, status])
476+
@@index([endDate])
477+
@@map("ai_subscriptions")
478+
}
479+
480+
// ==================== AI 订阅申请模型 ====================
481+
model AiSubscriptionRequest {
482+
id String @id @default(cuid())
483+
userId String
484+
plan AiPlan
485+
billingPeriod AiBillingPeriod
486+
status AiRequestStatus @default(PENDING)
487+
reason String? // 用户申请理由
488+
rejectReason String? // 管理员拒绝原因
489+
reviewedBy String? // 审批管理员 ID
490+
reviewedAt DateTime?
491+
createdAt DateTime @default(now())
492+
updatedAt DateTime @updatedAt
493+
494+
user User @relation("UserAiSubscriptionRequests", fields: [userId], references: [id], onDelete: Cascade)
495+
496+
@@index([status, createdAt(sort: Desc)])
497+
@@index([userId, createdAt(sort: Desc)])
498+
@@map("ai_subscription_requests")
499+
}

apps/api/src/activity/activity.service.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -252,23 +252,21 @@ export class ActivityService {
252252
}),
253253
]);
254254

255-
// 30 天文档增长趋势
256-
const docGrowth = await this.prisma.document.groupBy({
257-
by: ['createdAt'],
255+
// 30 天文档增长趋势(按日期聚合,非按时间戳)
256+
const recentDocs = await this.prisma.document.findMany({
258257
where: {
259258
spaceId,
260259
deletedAt: null,
261260
createdAt: { gte: thirtyDaysAgo },
262261
},
263-
_count: true,
264-
orderBy: { createdAt: 'asc' },
262+
select: { createdAt: true },
265263
});
266264

267265
// 按日聚合文档增长
268266
const growthByDay: Record<string, number> = {};
269-
for (const item of docGrowth) {
270-
const day = new Date(item.createdAt).toISOString().slice(0, 10);
271-
growthByDay[day] = (growthByDay[day] || 0) + item._count;
267+
for (const doc of recentDocs) {
268+
const day = new Date(doc.createdAt).toISOString().slice(0, 10);
269+
growthByDay[day] = (growthByDay[day] || 0) + 1;
272270
}
273271
const docGrowthTrend = [];
274272
for (let i = 29; i >= 0; i--) {

0 commit comments

Comments
 (0)