Skip to content

Commit fba8b6a

Browse files
committed
feat: AI 응답 데이터 파싱 로직 구현
1 parent dace547 commit fba8b6a

File tree

6 files changed

+256
-83
lines changed

6 files changed

+256
-83
lines changed

client/src/features/editor/utils/domSyncUtils.ts

Lines changed: 0 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -188,76 +188,6 @@ const nodesAreEqual = (node1: Node, node2: Node): boolean => {
188188
return false;
189189
};
190190

191-
// export const setInnerHTML = ({ element, block }: SetInnerHTMLProps): void => {
192-
// const chars = block.crdt.LinkedList.spread();
193-
// if (chars.length === 0) {
194-
// element.innerHTML = "";
195-
// return;
196-
// }
197-
198-
// // 각 위치별 모든 적용된 스타일을 추적
199-
// const positionStyles: TextStyleState[] = chars.map((char) => {
200-
// const styleSet = new Set<string>();
201-
202-
// // 현재 문자의 스타일 수집
203-
// char.style.forEach((style) => styleSet.add(TEXT_STYLES[style]));
204-
205-
// return {
206-
// styles: styleSet,
207-
// color: char.color,
208-
// backgroundColor: char.backgroundColor,
209-
// };
210-
// });
211-
212-
// let html = "";
213-
// let currentState: TextStyleState = {
214-
// styles: new Set<string>(),
215-
// color: "black",
216-
// backgroundColor: "transparent",
217-
// };
218-
// let spanOpen = false;
219-
220-
// chars.forEach((char, index) => {
221-
// const targetState = positionStyles[index];
222-
223-
// // 스타일, 색상, 배경색 변경 확인
224-
// const styleChanged =
225-
// !setsEqual(currentState.styles, targetState.styles) ||
226-
// currentState.color !== targetState.color ||
227-
// currentState.backgroundColor !== targetState.backgroundColor;
228-
229-
// // 변경되었으면 현재 span 태그 닫기
230-
// if (styleChanged && spanOpen) {
231-
// html += "</span>";
232-
// spanOpen = false;
233-
// }
234-
235-
// // 새로운 스타일 조합으로 span 태그 열기
236-
// if (styleChanged) {
237-
// const className = getClassNames(targetState);
238-
// html += `<span class="${className}" style="white-space: pre;">`;
239-
// spanOpen = true;
240-
// }
241-
242-
// // 텍스트 추가
243-
// html += sanitizeText(char.value);
244-
245-
// // 다음 문자로 넘어가기 전에 현재 상태 업데이트
246-
// currentState = targetState;
247-
248-
// // 마지막 문자이고 span이 열려있으면 닫기
249-
// if (index === chars.length - 1 && spanOpen) {
250-
// html += "</span>";
251-
// spanOpen = false;
252-
// }
253-
// });
254-
255-
// // DOM 업데이트
256-
// if (element.innerHTML !== html) {
257-
// element.innerHTML = html;
258-
// }
259-
// };
260-
261191
// Set 비교 헬퍼 함수
262192
const setsEqual = (a: Set<string>, b: Set<string>): boolean => {
263193
if (a.size !== b.size) return false;
@@ -267,19 +197,6 @@ const setsEqual = (a: Set<string>, b: Set<string>): boolean => {
267197
return true;
268198
};
269199

270-
// const sanitizeText = (text: string): string => {
271-
// return text.replace(/<br>/g, "\u00A0").replace(/[<>&"']/g, (match) => {
272-
// const escapeMap: Record<string, string> = {
273-
// "<": "&lt;",
274-
// ">": "&gt;",
275-
// "&": "&amp;",
276-
// '"': "&quot;",
277-
// "'": "&#x27;",
278-
// };
279-
// return escapeMap[match] || match;
280-
// });
281-
// };
282-
283200
// 배열 비교 헬퍼 함수
284201
export const arraysEqual = (a: string[], b: string[]): boolean => {
285202
if (a.length !== b.length) return false;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { AiController } from './ai.controller';
3+
import { AiService } from './ai.service';
4+
5+
describe('AiController', () => {
6+
let controller: AiController;
7+
8+
beforeEach(async () => {
9+
const module: TestingModule = await Test.createTestingModule({
10+
controllers: [AiController],
11+
providers: [AiService],
12+
}).compile();
13+
14+
controller = module.get<AiController>(AiController);
15+
});
16+
17+
it('should be defined', () => {
18+
expect(controller).toBeDefined();
19+
});
20+
});

server/src/ai/ai.controller.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import {
2+
Controller,
3+
Get,
4+
Post,
5+
Body,
6+
UseGuards,
7+
Request,
8+
Response,
9+
UnauthorizedException,
10+
ConflictException,
11+
BadRequestException,
12+
Logger,
13+
} from "@nestjs/common";
14+
import { AiService } from './ai.service';
15+
import {
16+
ApiTags,
17+
ApiOperation,
18+
ApiBody,
19+
ApiResponse,
20+
ApiBearerAuth,
21+
ApiCookieAuth,
22+
} from "@nestjs/swagger";
23+
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
24+
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
25+
26+
@ApiTags("ai")
27+
// @UseGuards(JwtAuthGuard)
28+
@Controller('ai')
29+
export class AiController {
30+
private readonly logger = new Logger();
31+
constructor(private readonly aiService: AiService) {}
32+
33+
// 사용자의 AI 요청을 받는 POST 메서드
34+
@Post("chat")
35+
@ApiOperation({ summary: "Chat to AI" })
36+
@ApiBody({
37+
schema: {
38+
type: "object",
39+
properties: {
40+
clientId: { type: "number" },
41+
workspaceId: { type: "string" },
42+
message: { type: "string" },
43+
},
44+
},
45+
})
46+
@ApiResponse({ status: 200, description: "good" })
47+
@ApiResponse({ status: 401, description: "Unauthorized" })
48+
async chat(
49+
@Body() body: { clientId: number, workspaceId: string, message: string },
50+
@Response({ passthrough: true }) res: ExpressResponse,
51+
): Promise<Object> {
52+
const operations = await this.aiService.generateDocumentToCRDT(body.workspaceId, body.clientId, body.message);
53+
operations.forEach(operation => {
54+
console.log(operation);
55+
});
56+
return { message: operations };
57+
}
58+
}

server/src/ai/ai.module.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Module } from "@nestjs/common";
2+
import { JwtModule } from "@nestjs/jwt";
3+
import { PassportModule } from "@nestjs/passport";
4+
import { AiService } from "./ai.service";
5+
import { AiController } from "./ai.controller";
6+
import { AuthModule } from "../auth/auth.module";
7+
import { WorkspaceModule } from "../workspace/workspace.module";
8+
import { ConfigModule, ConfigService } from "@nestjs/config";
9+
import { JwtStrategy } from "../auth/strategies/jwt.strategy";
10+
import { JwtRefreshTokenStrategy } from "../auth/strategies/jwt-refresh-token.strategy";
11+
import { JwtRefreshTokenAuthGuard } from "../auth/guards/jwt-refresh-token-auth.guard";
12+
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
13+
14+
@Module({
15+
imports: [
16+
AuthModule,
17+
WorkspaceModule,
18+
PassportModule,
19+
JwtModule.registerAsync({
20+
global: true,
21+
imports: [ConfigModule],
22+
inject: [ConfigService],
23+
useFactory: (config: ConfigService) => ({
24+
secret: config.get<string>("JWT_SECRET"),
25+
signOptions: { expiresIn: "1h" },
26+
}),
27+
}),
28+
],
29+
exports: [AiService, JwtModule],
30+
providers: [
31+
AiService,
32+
JwtStrategy,
33+
JwtRefreshTokenStrategy,
34+
JwtAuthGuard,
35+
JwtRefreshTokenAuthGuard,
36+
],
37+
controllers: [AiController],
38+
})
39+
export class AiModule {}

server/src/ai/ai.service.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Injectable, Logger } from "@nestjs/common";
2+
import { Operation, ElementType, RemoteBlockInsertOperation, RemotePageCreateOperation, RemoteCharInsertOperation, CRDTOperation } from "@noctaCrdt/types/Interfaces";
3+
import { Block } from "@noctaCrdt/Node";
4+
import { EditorCRDT } from "@noctaCrdt/Crdt";
5+
import { Page } from "@noctaCrdt/Page";
6+
import { nanoid } from "nanoid";
7+
import { WorkSpaceService } from "../workspace/workspace.service";
8+
import { BlockId, CharId } from "@noctaCrdt/NodeId";
9+
import { Char } from "@noctaCrdt/Node";
10+
11+
@Injectable()
12+
export class AiService {
13+
constructor(private readonly workspaceService: WorkSpaceService) {}
14+
15+
// CLOVA Studio API에 요청을 보내는 로직
16+
requestAI() {
17+
// 문자열 받고
18+
19+
// 문자열을 CRDT로 변환
20+
21+
// CRDT 연산들 적용해서 브로드캐스트
22+
23+
}
24+
25+
// 요청받은 답변을 CRDT 연산으로 변환하는 로직
26+
async generateDocumentToCRDT(workspaceId: string, clientId: number, document: String): Promise<Operation[]> {
27+
const operations = [];
28+
// 페이지 생성
29+
const workspace = await this.workspaceService.getWorkspace(workspaceId);
30+
const newEditorCRDT = new EditorCRDT(clientId);
31+
const newPage = new Page(nanoid(), "새로운 페이지", "Docs", newEditorCRDT);
32+
workspace.pageList.push(newPage);
33+
this.workspaceService.updateWorkspace(workspace);
34+
// 페이지 생성 연산 추가
35+
operations.push({
36+
type: "pageCreate",
37+
workspaceId,
38+
clientId,
39+
page: newPage.serialize()
40+
} as RemotePageCreateOperation);
41+
42+
let blockClock = 0;
43+
let charClock = 0;
44+
45+
const documentLines = document.split('\n');
46+
47+
let lastBlock = null;
48+
documentLines.forEach(line => {
49+
const {type, length, indent} = this.parseBlockType(line);
50+
const newBlock = new Block("", new BlockId(blockClock++, clientId));
51+
newBlock.next = null;
52+
newBlock.prev = lastBlock ? lastBlock.id : null;
53+
newBlock.type = type;
54+
newBlock.indent = indent;
55+
if (lastBlock) {
56+
lastBlock.next = newBlock.id;
57+
}
58+
lastBlock = newBlock;
59+
// 블록 추가 연산
60+
operations.push({
61+
type: "blockInsert",
62+
node: newBlock,
63+
pageId: newPage.id
64+
} as RemoteBlockInsertOperation);
65+
66+
const slicedLine = [...line.slice(length + indent * 2)];
67+
let lastNode = null;
68+
slicedLine.forEach(char => {
69+
const charNode = new Char(char, new CharId(charClock++, clientId));
70+
charNode.next = null;
71+
charNode.prev = lastNode ? lastNode.id : null;
72+
73+
// 이전 노드가 있는 경우, 해당 노드의 next를 현재 노드로 설정
74+
if (lastNode) {
75+
lastNode.next = charNode.id;
76+
}
77+
78+
lastNode = charNode;
79+
80+
operations.push({
81+
type: "charInsert",
82+
node: charNode,
83+
blockId: newBlock.id,
84+
pageId: newPage.id,
85+
style: charNode.style || [],
86+
color: charNode.color ? charNode.color : "black",
87+
backgroundColor: charNode.backgroundColor ? charNode.backgroundColor : "transparent",
88+
} as RemoteCharInsertOperation);
89+
});
90+
91+
// TODO: 스타일 적용
92+
// let start = 0, end = 0;
93+
// while 순회 end <-
94+
// 특수기호 *, **, ~~, __
95+
// 여는 애, 닫힌 애 **ㅁㄴㅇㄻㄴㅇㄹ**
96+
// stack [], cur **
97+
// stack [**], cur ㅁ
98+
// ...
99+
// stack [**], cur **
100+
// stack [], cur EOF
101+
102+
});
103+
104+
// 클라이언트에서 workspaceId, clientId, message 받아옴
105+
// 워크스페이스에서 페이지 생성 -> pageId: string(uuid)
106+
// 매 줄 처음마다 블록 생성 -> blockId: {client, clock}
107+
// 문자를 돌면서 CRDTOperation 연산 생성
108+
// 서식 문자(**, *, ~, __)를 만나면 해당 문자는 연산을 만들지 않고, 내부 글자에 대해 스타일 속성 추가후 연산 생성
109+
// 연산 배열 리턴
110+
111+
return operations;
112+
}
113+
114+
// CRDT 연산들을 페이지에 적용하고 다른 클라이언트에 뿌리는 로직 (workspace.service)
115+
emitOperations() {
116+
117+
}
118+
119+
// 블록 타입을 판정하는 로직
120+
parseBlockType(line: String): {type: ElementType, length: number, indent : number} {
121+
122+
const indent = line.match(/^[\s]*/)[0].length / 2 || 0;
123+
const trimmed = line.trim();
124+
if (trimmed.startsWith('# ')) return { type: "h1", length: 2, indent};
125+
if (trimmed.startsWith('## ')) return { type: "h2", length: 3, indent };
126+
if (trimmed.startsWith('### ')) return { type: "h3", length: 4, indent };
127+
if (trimmed.startsWith('- ')) return { type: "ul", length: 2, indent };
128+
if (/^\d+\. /.test(trimmed)) return { type: "ol", length: 3, indent };
129+
if (trimmed.startsWith('> ')) return { type: "blockquote", length: 2, indent };
130+
if (trimmed.startsWith('[] ')) return {type: "checkbox", length: 3, indent };
131+
if (trimmed.startsWith('[x] ')) return {type: "checkbox", length: 4, indent};
132+
if (trimmed === '---') return {type: "hr", length: 0, indent};
133+
return {type: "p", length: 0, indent};
134+
};
135+
136+
parseCharType() {};
137+
}

server/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MongooseModule } from "@nestjs/mongoose";
66
import { AuthModule } from "./auth/auth.module";
77
import { WorkspaceModule } from "./workspace/workspace.module";
88
import { WorkspaceController } from "./workspace/workspace.controller";
9+
import { AiModule } from './ai/ai.module';
910

1011
@Module({
1112
imports: [
@@ -24,6 +25,7 @@ import { WorkspaceController } from "./workspace/workspace.controller";
2425
}),
2526
AuthModule,
2627
WorkspaceModule,
28+
AiModule,
2729
],
2830
controllers: [AppController, WorkspaceController],
2931
providers: [AppService],

0 commit comments

Comments
 (0)