Features - User Management (Phase 4.1): - Database: Add user_modules table for fine-grained module permissions - Database: Add 4 user permissions (view/create/edit/delete) to role_permissions - Backend: UserService (780 lines) - CRUD with tenant isolation - Backend: UserController + UserRoutes (648 lines) - 13 API endpoints - Backend: Batch import users from Excel - Frontend: UserListPage (412 lines) - list/filter/search/pagination - Frontend: UserFormPage (341 lines) - create/edit with module config - Frontend: UserDetailPage (393 lines) - details/tenant/module management - Frontend: 3 modal components (592 lines) - import/assign/configure - API: GET/POST/PUT/DELETE /api/admin/users/* endpoints Architecture Upgrade - Module Permission System: - Backend: Add getUserModules() method in auth.service - Backend: Login API returns modules array in user object - Frontend: AuthContext adds hasModule() method - Frontend: Navigation filters modules based on user.modules - Frontend: RouteGuard checks requiredModule instead of requiredVersion - Frontend: Remove deprecated version-based permission system - UX: Only show accessible modules in navigation (clean UI) - UX: Smart redirect after login (avoid 403 for regular users) Fixes: - Fix UTF-8 encoding corruption in ~100 docs files - Fix pageSize type conversion in userService (String to Number) - Fix authUser undefined error in TopNavigation - Fix login redirect logic with role-based access check - Update Git commit guidelines v1.2 with UTF-8 safety rules Database Changes: - CREATE TABLE user_modules (user_id, tenant_id, module_code, is_enabled) - ADD UNIQUE CONSTRAINT (user_id, tenant_id, module_code) - INSERT 4 permissions + role assignments - UPDATE PUBLIC tenant with 8 module subscriptions Technical: - Backend: 5 new files (~2400 lines) - Frontend: 10 new files (~2500 lines) - Docs: 1 development record + 2 status updates + 1 guideline update - Total: ~4900 lines of code Status: User management 100% complete, module permission system operational
205 lines
5.8 KiB
TypeScript
205 lines
5.8 KiB
TypeScript
/**
|
||
* 通用流式响应服务
|
||
*
|
||
* 封装 LLM 调用 + OpenAI Compatible 输出
|
||
* 支持深度思考、Token 统计、错误处理
|
||
*/
|
||
|
||
import { FastifyReply } from 'fastify';
|
||
import { OpenAIStreamAdapter, createOpenAIStreamAdapter } from './OpenAIStreamAdapter';
|
||
import { StreamOptions, StreamCallbacks, THINKING_TAGS, OpenAIMessage } from './types';
|
||
import { LLMFactory } from '../llm/adapters/LLMFactory';
|
||
import type { Message as LLMMessage } from '../llm/adapters/types';
|
||
import { logger } from '../logging/logger';
|
||
|
||
/**
|
||
* 深度思考标签处理结果
|
||
*/
|
||
interface ThinkingParseResult {
|
||
content: string;
|
||
thinking: string;
|
||
inThinking: boolean;
|
||
}
|
||
|
||
/**
|
||
* 流式响应服务
|
||
*/
|
||
export class StreamingService {
|
||
private adapter: OpenAIStreamAdapter;
|
||
private options: StreamOptions;
|
||
private fullContent: string = '';
|
||
private thinkingContent: string = '';
|
||
private isInThinking: boolean = false;
|
||
|
||
constructor(reply: FastifyReply, options: StreamOptions = {}) {
|
||
this.adapter = createOpenAIStreamAdapter(reply, options.model);
|
||
this.options = options;
|
||
}
|
||
|
||
/**
|
||
* 执行流式生成
|
||
*/
|
||
async streamGenerate(
|
||
messages: OpenAIMessage[],
|
||
callbacks?: StreamCallbacks
|
||
): Promise<{ content: string; thinking: string; messageId: string }> {
|
||
const { model = 'deepseek-v3', temperature = 0.7, maxTokens = 4096 } = this.options;
|
||
|
||
try {
|
||
// 获取 LLM 适配器
|
||
const llm = LLMFactory.getAdapter(model as any);
|
||
|
||
// 发送角色开始标识
|
||
this.adapter.sendRoleStart();
|
||
|
||
// 流式生成
|
||
const stream = llm.chatStream(
|
||
messages as LLMMessage[],
|
||
{ temperature, maxTokens }
|
||
);
|
||
|
||
for await (const chunk of stream) {
|
||
if (chunk.content) {
|
||
// 处理深度思考标签
|
||
const { content, thinking, inThinking } = this.processThinkingTags(
|
||
chunk.content,
|
||
this.options.enableDeepThinking ?? false
|
||
);
|
||
|
||
// 发送思考内容
|
||
if (thinking) {
|
||
this.thinkingContent += thinking;
|
||
this.adapter.sendReasoningDelta(thinking);
|
||
callbacks?.onThinking?.(thinking);
|
||
}
|
||
|
||
// 发送正文内容
|
||
if (content) {
|
||
this.fullContent += content;
|
||
this.adapter.sendContentDelta(content);
|
||
callbacks?.onContent?.(content);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 发送完成标识
|
||
const usage = {
|
||
promptTokens: this.estimateTokens(messages.map(m => m.content).join('')),
|
||
completionTokens: this.estimateTokens(this.fullContent),
|
||
totalTokens: 0,
|
||
};
|
||
usage.totalTokens = usage.promptTokens + usage.completionTokens;
|
||
|
||
this.adapter.sendComplete(usage);
|
||
this.adapter.end();
|
||
|
||
// 完成回调
|
||
callbacks?.onComplete?.(this.fullContent, this.thinkingContent);
|
||
|
||
logger.info('[StreamingService] 流式生成完成', {
|
||
conversationId: this.options.conversationId,
|
||
contentLength: this.fullContent.length,
|
||
thinkingLength: this.thinkingContent.length,
|
||
tokens: usage.totalTokens,
|
||
});
|
||
|
||
return {
|
||
content: this.fullContent,
|
||
thinking: this.thinkingContent,
|
||
messageId: this.adapter.getMessageId(),
|
||
};
|
||
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : '流式生成失败';
|
||
this.adapter.sendError(errorMessage);
|
||
this.adapter.end();
|
||
|
||
callbacks?.onError?.(error instanceof Error ? error : new Error(errorMessage));
|
||
|
||
logger.error('[StreamingService] 流式生成失败', {
|
||
error,
|
||
conversationId: this.options.conversationId,
|
||
});
|
||
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理深度思考标签
|
||
*/
|
||
private processThinkingTags(text: string, enableDeepThinking: boolean): ThinkingParseResult {
|
||
if (!enableDeepThinking) {
|
||
return { content: text, thinking: '', inThinking: this.isInThinking };
|
||
}
|
||
|
||
let content = '';
|
||
let thinking = '';
|
||
let remaining = text;
|
||
|
||
while (remaining.length > 0) {
|
||
if (this.isInThinking) {
|
||
// 在思考模式中,查找结束标签
|
||
const endIndex = remaining.indexOf(THINKING_TAGS.END);
|
||
if (endIndex !== -1) {
|
||
thinking += remaining.substring(0, endIndex);
|
||
remaining = remaining.substring(endIndex + THINKING_TAGS.END.length);
|
||
this.isInThinking = false;
|
||
} else {
|
||
thinking += remaining;
|
||
remaining = '';
|
||
}
|
||
} else {
|
||
// 不在思考模式,查找开始标签
|
||
const startIndex = remaining.indexOf(THINKING_TAGS.START);
|
||
if (startIndex !== -1) {
|
||
content += remaining.substring(0, startIndex);
|
||
remaining = remaining.substring(startIndex + THINKING_TAGS.START.length);
|
||
this.isInThinking = true;
|
||
} else {
|
||
content += remaining;
|
||
remaining = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
return { content, thinking, inThinking: this.isInThinking };
|
||
}
|
||
|
||
/**
|
||
* 估算 Token 数量(简单实现)
|
||
*/
|
||
private estimateTokens(text: string): number {
|
||
// 中文约 1.5 字符/token,英文约 4 字符/token
|
||
const chineseChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||
const otherChars = text.length - chineseChars;
|
||
return Math.ceil(chineseChars / 1.5 + otherChars / 4);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建流式响应服务
|
||
*/
|
||
export function createStreamingService(
|
||
reply: FastifyReply,
|
||
options?: StreamOptions
|
||
): StreamingService {
|
||
return new StreamingService(reply, options);
|
||
}
|
||
|
||
/**
|
||
* 快捷方法:直接执行流式生成
|
||
*/
|
||
export async function streamChat(
|
||
reply: FastifyReply,
|
||
messages: OpenAIMessage[],
|
||
options?: StreamOptions,
|
||
callbacks?: StreamCallbacks
|
||
) {
|
||
const service = createStreamingService(reply, options);
|
||
return service.streamGenerate(messages, callbacks);
|
||
}
|
||
|
||
|
||
|