mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
Merge pull request #27 from Memo-2023/claude/plan-questions-app-UKqD5
Add Mana Search Service design document and architecture
This commit is contained in:
commit
a930e285b2
125 changed files with 10849 additions and 2 deletions
1597
.claude/plans/mana-search-service.md
Normal file
1597
.claude/plans/mana-search-service.md
Normal file
File diff suppressed because it is too large
Load diff
1471
.claude/plans/questions-app.md
Normal file
1471
.claude/plans/questions-app.md
Normal file
File diff suppressed because it is too large
Load diff
186
apps/questions/CLAUDE.md
Normal file
186
apps/questions/CLAUDE.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# Questions App
|
||||
|
||||
AI-powered research assistant that collects user questions and performs comprehensive research using the mana-search microservice.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Backend Port**: 3011
|
||||
- **Web Port**: 5111
|
||||
- **Technology**: NestJS + Drizzle ORM + PostgreSQL + SvelteKit
|
||||
- **Search**: mana-search microservice (SearXNG)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Questions App │
|
||||
│ Collections │ Questions │ Research │ Answers │ Sources │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ mana-search (Port 3021) │
|
||||
│ Search API │ Extract API │ Redis Cache │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SearXNG (Port 8080) │
|
||||
│ Google │ Bing │ arXiv │ Wikipedia │ GitHub │ ... │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Start infrastructure (PostgreSQL, Redis, mana-search dependencies)
|
||||
pnpm docker:up
|
||||
|
||||
# 2. Start everything (auth, search, backend, web):
|
||||
pnpm dev:questions:full
|
||||
|
||||
# Or start components individually:
|
||||
pnpm dev:questions:backend # Just backend (port 3011)
|
||||
pnpm dev:questions:web # Just web (port 5111)
|
||||
pnpm dev:search:full # Just search service (port 3021)
|
||||
```
|
||||
|
||||
## Web App
|
||||
|
||||
The SvelteKit web app provides:
|
||||
|
||||
- **Question Management**: Create, edit, and organize questions
|
||||
- **Collection Organization**: Group questions into collections with colors/icons
|
||||
- **Research Interface**: Start research and view results with sources
|
||||
- **Source Viewer**: Explore extracted content from web sources
|
||||
- **Dark Mode**: Full theme support
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Collections
|
||||
|
||||
```bash
|
||||
POST /api/v1/collections # Create collection
|
||||
GET /api/v1/collections # List collections
|
||||
GET /api/v1/collections/:id # Get collection
|
||||
PUT /api/v1/collections/:id # Update collection
|
||||
DELETE /api/v1/collections/:id # Delete collection
|
||||
POST /api/v1/collections/reorder # Reorder collections
|
||||
```
|
||||
|
||||
### Questions
|
||||
|
||||
```bash
|
||||
POST /api/v1/questions # Create question
|
||||
GET /api/v1/questions # List questions (with filters)
|
||||
GET /api/v1/questions/:id # Get question
|
||||
PUT /api/v1/questions/:id # Update question
|
||||
DELETE /api/v1/questions/:id # Delete question
|
||||
PUT /api/v1/questions/:id/status # Update status
|
||||
```
|
||||
|
||||
### Research
|
||||
|
||||
```bash
|
||||
POST /api/v1/research/start # Start research
|
||||
GET /api/v1/research/question/:id # Get results for question
|
||||
GET /api/v1/research/:id # Get research result
|
||||
GET /api/v1/research/health/search # Check search service
|
||||
```
|
||||
|
||||
### Answers
|
||||
|
||||
```bash
|
||||
POST /api/v1/answers # Create answer
|
||||
GET /api/v1/answers/question/:id # List answers for question
|
||||
GET /api/v1/answers/question/:id/accepted # Get accepted answer
|
||||
GET /api/v1/answers/:id # Get answer
|
||||
PUT /api/v1/answers/:id # Update answer
|
||||
POST /api/v1/answers/:id/rate # Rate answer
|
||||
POST /api/v1/answers/:id/accept # Accept answer
|
||||
DELETE /api/v1/answers/:id # Delete answer
|
||||
```
|
||||
|
||||
### Sources
|
||||
|
||||
```bash
|
||||
GET /api/v1/sources/research/:id # Sources by research result
|
||||
GET /api/v1/sources/question/:id # All sources for question
|
||||
GET /api/v1/sources/:id # Get source
|
||||
GET /api/v1/sources/:id/content # Get source content
|
||||
```
|
||||
|
||||
## Research Depths
|
||||
|
||||
| Depth | Sources | Extraction | Categories |
|
||||
|-------|---------|------------|------------|
|
||||
| `quick` | 5 | No | general |
|
||||
| `standard` | 15 | Yes | general, news |
|
||||
| `deep` | 30 | Yes | general, news, science, it |
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
-- Collections for organizing questions
|
||||
collections (id, user_id, name, description, color, icon, sort_order, ...)
|
||||
|
||||
-- User questions
|
||||
questions (id, user_id, collection_id, title, description, status, priority, tags, ...)
|
||||
|
||||
-- Research results from mana-search
|
||||
research_results (id, question_id, summary, key_points, follow_up_questions, ...)
|
||||
|
||||
-- Extracted sources from search
|
||||
sources (id, research_result_id, url, title, snippet, extracted_content, ...)
|
||||
|
||||
-- AI-generated answers
|
||||
answers (id, question_id, research_result_id, content, rating, is_accepted, ...)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | 3011 | Backend port |
|
||||
| `DATABASE_URL` | - | PostgreSQL connection |
|
||||
| `MANA_CORE_AUTH_URL` | http://localhost:3001 | Auth service URL |
|
||||
| `MANA_SEARCH_URL` | http://localhost:3021 | Search service URL |
|
||||
| `MANA_SEARCH_TIMEOUT` | 30000 | Search timeout (ms) |
|
||||
| `DEV_BYPASS_AUTH` | false | Skip auth in dev |
|
||||
| `DEV_USER_ID` | - | User ID when auth bypassed |
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Backend only
|
||||
pnpm dev:questions:backend
|
||||
|
||||
# Type checking
|
||||
cd apps/questions/apps/backend && pnpm type-check
|
||||
|
||||
# Database
|
||||
cd apps/questions/apps/backend
|
||||
pnpm drizzle-kit generate # Generate migrations
|
||||
pnpm drizzle-kit push # Push schema to DB
|
||||
pnpm drizzle-kit studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
## Testing the API
|
||||
|
||||
```bash
|
||||
# Create a collection
|
||||
curl -X POST http://localhost:3011/api/v1/collections \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"name": "Tech Research", "color": "#6366f1"}'
|
||||
|
||||
# Create a question
|
||||
curl -X POST http://localhost:3011/api/v1/questions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"title": "What are the best practices for TypeScript?", "researchDepth": "standard"}'
|
||||
|
||||
# Start research
|
||||
curl -X POST http://localhost:3011/api/v1/research/start \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{"questionId": "uuid-here", "depth": "standard"}'
|
||||
```
|
||||
18
apps/questions/apps/backend/.env.example
Normal file
18
apps/questions/apps/backend/.env.example
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Server
|
||||
PORT=3011
|
||||
NODE_ENV=development
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/questions
|
||||
|
||||
# Auth
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
DEV_BYPASS_AUTH=true
|
||||
DEV_USER_ID=dev-user-id
|
||||
|
||||
# Mana Search Service
|
||||
MANA_SEARCH_URL=http://localhost:3021
|
||||
MANA_SEARCH_TIMEOUT=30000
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8081
|
||||
11
apps/questions/apps/backend/drizzle.config.ts
Normal file
11
apps/questions/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import 'dotenv/config';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './src/db/schema/*.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
8
apps/questions/apps/backend/nest-cli.json
Normal file
8
apps/questions/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
53
apps/questions/apps/backend/package.json
Normal file
53
apps/questions/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "@questions/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"db:seed": "tsx src/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
79
apps/questions/apps/backend/src/answer/answer.controller.ts
Normal file
79
apps/questions/apps/backend/src/answer/answer.controller.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { AnswerService } from './answer.service';
|
||||
import { CreateAnswerDto, UpdateAnswerDto, RateAnswerDto, AcceptAnswerDto } from './dto';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
|
||||
@Controller('answers')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AnswerController {
|
||||
constructor(private readonly answerService: AnswerService) {}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateAnswerDto) {
|
||||
return this.answerService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Get('question/:questionId')
|
||||
async findByQuestion(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('questionId', ParseUUIDPipe) questionId: string,
|
||||
) {
|
||||
return this.answerService.findByQuestion(user.userId, questionId);
|
||||
}
|
||||
|
||||
@Get('question/:questionId/accepted')
|
||||
async getAccepted(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('questionId', ParseUUIDPipe) questionId: string,
|
||||
) {
|
||||
return this.answerService.getAccepted(user.userId, questionId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.answerService.findOne(user.userId, id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateAnswerDto,
|
||||
) {
|
||||
return this.answerService.update(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/rate')
|
||||
async rate(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RateAnswerDto,
|
||||
) {
|
||||
return this.answerService.rate(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/accept')
|
||||
async setAccepted(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptAnswerDto,
|
||||
) {
|
||||
return this.answerService.setAccepted(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.answerService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
12
apps/questions/apps/backend/src/answer/answer.module.ts
Normal file
12
apps/questions/apps/backend/src/answer/answer.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AnswerController } from './answer.controller';
|
||||
import { AnswerService } from './answer.service';
|
||||
import { DatabaseModule } from '../db/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [AnswerController],
|
||||
providers: [AnswerService],
|
||||
exports: [AnswerService],
|
||||
})
|
||||
export class AnswerModule {}
|
||||
159
apps/questions/apps/backend/src/answer/answer.service.ts
Normal file
159
apps/questions/apps/backend/src/answer/answer.service.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { questions, answers, Answer, NewAnswer } from '../db/schema';
|
||||
import { CreateAnswerDto, UpdateAnswerDto, RateAnswerDto, AcceptAnswerDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class AnswerService {
|
||||
constructor(
|
||||
@Inject('DATABASE_CONNECTION')
|
||||
private readonly db: NodePgDatabase,
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateAnswerDto): Promise<Answer> {
|
||||
// Verify user owns the question
|
||||
const [question] = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.id, dto.questionId), eq(questions.userId, userId)));
|
||||
|
||||
if (!question) {
|
||||
throw new NotFoundException(`Question with id ${dto.questionId} not found`);
|
||||
}
|
||||
|
||||
const newAnswer: NewAnswer = {
|
||||
questionId: dto.questionId,
|
||||
researchResultId: dto.researchResultId,
|
||||
content: dto.content,
|
||||
contentMarkdown: dto.contentMarkdown,
|
||||
summary: dto.summary,
|
||||
modelId: dto.modelId,
|
||||
provider: dto.provider,
|
||||
promptTokens: dto.promptTokens,
|
||||
completionTokens: dto.completionTokens,
|
||||
estimatedCost: dto.estimatedCost,
|
||||
confidence: dto.confidence,
|
||||
sourceCount: dto.sourceCount,
|
||||
citations: dto.citations || [],
|
||||
durationMs: dto.durationMs,
|
||||
};
|
||||
|
||||
const [created] = await this.db.insert(answers).values(newAnswer).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findByQuestion(userId: string, questionId: string): Promise<Answer[]> {
|
||||
// Verify user owns the question
|
||||
const [question] = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.id, questionId), eq(questions.userId, userId)));
|
||||
|
||||
if (!question) {
|
||||
throw new NotFoundException(`Question with id ${questionId} not found`);
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(answers)
|
||||
.where(eq(answers.questionId, questionId))
|
||||
.orderBy(desc(answers.createdAt));
|
||||
}
|
||||
|
||||
async findOne(userId: string, id: string): Promise<Answer> {
|
||||
const [answer] = await this.db.select().from(answers).where(eq(answers.id, id));
|
||||
|
||||
if (!answer) {
|
||||
throw new NotFoundException(`Answer with id ${id} not found`);
|
||||
}
|
||||
|
||||
// Verify user owns the question
|
||||
const [question] = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.id, answer.questionId), eq(questions.userId, userId)));
|
||||
|
||||
if (!question) {
|
||||
throw new NotFoundException('Answer not found');
|
||||
}
|
||||
|
||||
return answer;
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, dto: UpdateAnswerDto): Promise<Answer> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
const updateData: Partial<NewAnswer> = {
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const [updated] = await this.db.update(answers).set(updateData).where(eq(answers.id, id)).returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async rate(userId: string, id: string, dto: RateAnswerDto): Promise<Answer> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(answers)
|
||||
.set({
|
||||
rating: dto.rating,
|
||||
feedback: dto.feedback,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(answers.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async setAccepted(userId: string, id: string, dto: AcceptAnswerDto): Promise<Answer> {
|
||||
const answer = await this.findOne(userId, id);
|
||||
|
||||
// If accepting, unset other accepted answers for this question
|
||||
if (dto.isAccepted) {
|
||||
await this.db
|
||||
.update(answers)
|
||||
.set({ isAccepted: false, updatedAt: new Date() })
|
||||
.where(and(eq(answers.questionId, answer.questionId), eq(answers.isAccepted, true)));
|
||||
}
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(answers)
|
||||
.set({
|
||||
isAccepted: dto.isAccepted,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(answers.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
await this.findOne(userId, id);
|
||||
await this.db.delete(answers).where(eq(answers.id, id));
|
||||
}
|
||||
|
||||
async getAccepted(userId: string, questionId: string): Promise<Answer | null> {
|
||||
// Verify user owns the question
|
||||
const [question] = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.id, questionId), eq(questions.userId, userId)));
|
||||
|
||||
if (!question) {
|
||||
throw new NotFoundException(`Question with id ${questionId} not found`);
|
||||
}
|
||||
|
||||
const [accepted] = await this.db
|
||||
.select()
|
||||
.from(answers)
|
||||
.where(and(eq(answers.questionId, questionId), eq(answers.isAccepted, true)));
|
||||
|
||||
return accepted || null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { IsUUID, IsString, IsOptional, IsNumber, IsArray } from 'class-validator';
|
||||
|
||||
export class CreateAnswerDto {
|
||||
@IsUUID()
|
||||
questionId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
researchResultId?: string;
|
||||
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contentMarkdown?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
summary?: string;
|
||||
|
||||
@IsString()
|
||||
modelId: string;
|
||||
|
||||
@IsString()
|
||||
provider: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
promptTokens?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
completionTokens?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
estimatedCost?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
confidence?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sourceCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
citations?: any[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
durationMs?: number;
|
||||
}
|
||||
2
apps/questions/apps/backend/src/answer/dto/index.ts
Normal file
2
apps/questions/apps/backend/src/answer/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-answer.dto';
|
||||
export * from './update-answer.dto';
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { IsOptional, IsString, IsNumber, IsBoolean, Min, Max } from 'class-validator';
|
||||
|
||||
export class UpdateAnswerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contentMarkdown?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export class RateAnswerDto {
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
rating: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
export class AcceptAnswerDto {
|
||||
@IsBoolean()
|
||||
isAccepted: boolean;
|
||||
}
|
||||
27
apps/questions/apps/backend/src/app.module.ts
Normal file
27
apps/questions/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import configuration from './config/configuration';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { QuestionModule } from './question/question.module';
|
||||
import { CollectionModule } from './collection/collection.module';
|
||||
import { ResearchModule } from './research/research.module';
|
||||
import { AnswerModule } from './answer/answer.module';
|
||||
import { SourceModule } from './source/source.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [configuration],
|
||||
}),
|
||||
DatabaseModule,
|
||||
HealthModule,
|
||||
QuestionModule,
|
||||
CollectionModule,
|
||||
ResearchModule,
|
||||
AnswerModule,
|
||||
SourceModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { CollectionService } from './collection.service';
|
||||
import { CreateCollectionDto, UpdateCollectionDto } from './dto';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
|
||||
@Controller('collections')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CollectionController {
|
||||
constructor(private readonly collectionService: CollectionService) {}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateCollectionDto) {
|
||||
return this.collectionService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
return this.collectionService.findAll(user.userId);
|
||||
}
|
||||
|
||||
@Get('default')
|
||||
async getDefault(@CurrentUser() user: CurrentUserData) {
|
||||
return this.collectionService.getDefault(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.collectionService.findOne(user.userId, id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateCollectionDto,
|
||||
) {
|
||||
return this.collectionService.update(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.collectionService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
async reorder(@CurrentUser() user: CurrentUserData, @Body('orderedIds') orderedIds: string[]) {
|
||||
await this.collectionService.reorder(user.userId, orderedIds);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CollectionController } from './collection.controller';
|
||||
import { CollectionService } from './collection.service';
|
||||
import { DatabaseModule } from '../db/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [CollectionController],
|
||||
providers: [CollectionService],
|
||||
exports: [CollectionService],
|
||||
})
|
||||
export class CollectionModule {}
|
||||
171
apps/questions/apps/backend/src/collection/collection.service.ts
Normal file
171
apps/questions/apps/backend/src/collection/collection.service.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { Injectable, Inject, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { eq, and, desc, isNull, count } from 'drizzle-orm';
|
||||
import { collections, Collection, NewCollection, questions } from '../db/schema';
|
||||
import { CreateCollectionDto, UpdateCollectionDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class CollectionService {
|
||||
constructor(
|
||||
@Inject('DATABASE_CONNECTION')
|
||||
private readonly db: NodePgDatabase,
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateCollectionDto): Promise<Collection> {
|
||||
// If this is set as default, unset other defaults
|
||||
if (dto.isDefault) {
|
||||
await this.db
|
||||
.update(collections)
|
||||
.set({ isDefault: false })
|
||||
.where(and(eq(collections.userId, userId), eq(collections.isDefault, true)));
|
||||
}
|
||||
|
||||
// Get max sort order
|
||||
const existing = await this.db
|
||||
.select({ sortOrder: collections.sortOrder })
|
||||
.from(collections)
|
||||
.where(and(eq(collections.userId, userId), isNull(collections.deletedAt)))
|
||||
.orderBy(desc(collections.sortOrder))
|
||||
.limit(1);
|
||||
|
||||
const maxSortOrder = existing.length > 0 ? existing[0].sortOrder ?? 0 : 0;
|
||||
|
||||
const newCollection: NewCollection = {
|
||||
userId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
color: dto.color || '#6366f1',
|
||||
icon: dto.icon || 'folder',
|
||||
isDefault: dto.isDefault || false,
|
||||
sortOrder: maxSortOrder + 1,
|
||||
};
|
||||
|
||||
const [created] = await this.db.insert(collections).values(newCollection).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findAll(userId: string): Promise<(Collection & { questionCount: number })[]> {
|
||||
const userCollections = await this.db
|
||||
.select()
|
||||
.from(collections)
|
||||
.where(and(eq(collections.userId, userId), isNull(collections.deletedAt)))
|
||||
.orderBy(collections.sortOrder);
|
||||
|
||||
// Get question counts for each collection
|
||||
const result = await Promise.all(
|
||||
userCollections.map(async (collection) => {
|
||||
const [countResult] = await this.db
|
||||
.select({ count: count() })
|
||||
.from(questions)
|
||||
.where(
|
||||
and(
|
||||
eq(questions.collectionId, collection.id),
|
||||
eq(questions.userId, userId),
|
||||
isNull(questions.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...collection,
|
||||
questionCount: countResult?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async findOne(userId: string, id: string): Promise<Collection> {
|
||||
const [collection] = await this.db
|
||||
.select()
|
||||
.from(collections)
|
||||
.where(
|
||||
and(eq(collections.id, id), eq(collections.userId, userId), isNull(collections.deletedAt)),
|
||||
);
|
||||
|
||||
if (!collection) {
|
||||
throw new NotFoundException(`Collection with id ${id} not found`);
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, dto: UpdateCollectionDto): Promise<Collection> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
// If setting as default, unset other defaults
|
||||
if (dto.isDefault) {
|
||||
await this.db
|
||||
.update(collections)
|
||||
.set({ isDefault: false })
|
||||
.where(and(eq(collections.userId, userId), eq(collections.isDefault, true)));
|
||||
}
|
||||
|
||||
const updateData: Partial<NewCollection> = {
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(collections)
|
||||
.set(updateData)
|
||||
.where(and(eq(collections.id, id), eq(collections.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
const collection = await this.findOne(userId, id);
|
||||
|
||||
// Check if collection has questions
|
||||
const [questionsCount] = await this.db
|
||||
.select({ count: count() })
|
||||
.from(questions)
|
||||
.where(
|
||||
and(
|
||||
eq(questions.collectionId, id),
|
||||
eq(questions.userId, userId),
|
||||
isNull(questions.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
if (questionsCount.count > 0) {
|
||||
throw new ConflictException(
|
||||
'Cannot delete collection with questions. Move or delete questions first.',
|
||||
);
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
await this.db
|
||||
.update(collections)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(eq(collections.id, id), eq(collections.userId, userId)));
|
||||
}
|
||||
|
||||
async getDefault(userId: string): Promise<Collection | null> {
|
||||
const [collection] = await this.db
|
||||
.select()
|
||||
.from(collections)
|
||||
.where(
|
||||
and(
|
||||
eq(collections.userId, userId),
|
||||
eq(collections.isDefault, true),
|
||||
isNull(collections.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
return collection || null;
|
||||
}
|
||||
|
||||
async reorder(userId: string, orderedIds: string[]): Promise<void> {
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) =>
|
||||
this.db
|
||||
.update(collections)
|
||||
.set({ sortOrder: index, updatedAt: new Date() })
|
||||
.where(and(eq(collections.id, id), eq(collections.userId, userId))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { IsString, IsOptional, IsBoolean } from 'class-validator';
|
||||
|
||||
export class CreateCollectionDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
2
apps/questions/apps/backend/src/collection/dto/index.ts
Normal file
2
apps/questions/apps/backend/src/collection/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-collection.dto';
|
||||
export * from './update-collection.dto';
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { IsString, IsOptional, IsBoolean, IsNumber } from 'class-validator';
|
||||
|
||||
export class UpdateCollectionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
icon?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
}
|
||||
27
apps/questions/apps/backend/src/config/configuration.ts
Normal file
27
apps/questions/apps/backend/src/config/configuration.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export default () => ({
|
||||
port: parseInt(process.env.PORT || '3011', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
|
||||
database: {
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
|
||||
cors: {
|
||||
origins: process.env.CORS_ORIGINS?.split(',') || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:8081',
|
||||
],
|
||||
},
|
||||
|
||||
auth: {
|
||||
manaAuthUrl: process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001',
|
||||
devBypass: process.env.DEV_BYPASS_AUTH === 'true',
|
||||
devUserId: process.env.DEV_USER_ID,
|
||||
},
|
||||
|
||||
manaSearch: {
|
||||
url: process.env.MANA_SEARCH_URL || 'http://localhost:3021',
|
||||
timeout: parseInt(process.env.MANA_SEARCH_TIMEOUT || '30000', 10),
|
||||
},
|
||||
});
|
||||
15
apps/questions/apps/backend/src/db/connection.ts
Normal file
15
apps/questions/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
|
||||
const client = postgres(connectionString, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
export type Database = typeof db;
|
||||
16
apps/questions/apps/backend/src/db/database.module.ts
Normal file
16
apps/questions/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { db } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useValue: db,
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
49
apps/questions/apps/backend/src/db/schema/answers.schema.ts
Normal file
49
apps/questions/apps/backend/src/db/schema/answers.schema.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { pgTable, uuid, text, integer, real, timestamp, jsonb, boolean } from 'drizzle-orm/pg-core';
|
||||
import { questions } from './questions.schema';
|
||||
import { researchResults } from './research.schema';
|
||||
|
||||
export const answers = pgTable('answers', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
questionId: uuid('question_id')
|
||||
.notNull()
|
||||
.references(() => questions.id, { onDelete: 'cascade' }),
|
||||
researchResultId: uuid('research_result_id').references(() => researchResults.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
|
||||
// Answer content
|
||||
content: text('content').notNull(),
|
||||
contentMarkdown: text('content_markdown'),
|
||||
summary: text('summary'), // Short summary of the answer
|
||||
|
||||
// Generation metadata
|
||||
modelId: text('model_id').notNull(),
|
||||
provider: text('provider').notNull(), // 'ollama', 'openrouter'
|
||||
|
||||
// Token tracking
|
||||
promptTokens: integer('prompt_tokens'),
|
||||
completionTokens: integer('completion_tokens'),
|
||||
estimatedCost: real('estimated_cost'),
|
||||
|
||||
// Quality indicators
|
||||
confidence: real('confidence'), // 0-1 confidence score
|
||||
sourceCount: integer('source_count'), // Number of sources used
|
||||
citations: jsonb('citations').default([]), // Array of citation references
|
||||
|
||||
// User feedback
|
||||
rating: integer('rating'), // 1-5 user rating
|
||||
feedback: text('feedback'), // User feedback text
|
||||
isAccepted: boolean('is_accepted').default(false), // User marked as accepted answer
|
||||
|
||||
// Versioning
|
||||
version: integer('version').default(1),
|
||||
previousVersionId: uuid('previous_version_id'),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
durationMs: integer('duration_ms'),
|
||||
});
|
||||
|
||||
export type Answer = typeof answers.$inferSelect;
|
||||
export type NewAnswer = typeof answers.$inferInsert;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { pgTable, uuid, text, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const collections = pgTable('collections', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
color: text('color').default('#6366f1'),
|
||||
icon: text('icon').default('folder'),
|
||||
|
||||
isShared: boolean('is_shared').default(false),
|
||||
shareToken: text('share_token').unique(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export type Collection = typeof collections.$inferSelect;
|
||||
export type NewCollection = typeof collections.$inferInsert;
|
||||
5
apps/questions/apps/backend/src/db/schema/index.ts
Normal file
5
apps/questions/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './questions.schema';
|
||||
export * from './collections.schema';
|
||||
export * from './research.schema';
|
||||
export * from './sources.schema';
|
||||
export * from './answers.schema';
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { pgTable, uuid, text, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
import { collections } from './collections.schema';
|
||||
|
||||
export const questions = pgTable('questions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: text('user_id').notNull(),
|
||||
collectionId: uuid('collection_id').references(() => collections.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
|
||||
// Content
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
|
||||
// Status & Priority
|
||||
status: text('status').notNull().default('open'), // 'open', 'researching', 'answered', 'archived'
|
||||
priority: text('priority').default('normal'), // 'low', 'normal', 'high', 'urgent'
|
||||
|
||||
// Categorization
|
||||
tags: text('tags').array().default([]),
|
||||
category: text('category'),
|
||||
|
||||
// Research config
|
||||
researchDepth: text('research_depth').default('quick'), // 'quick', 'standard', 'deep'
|
||||
autoResearch: boolean('auto_research').default(false),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
|
||||
answeredAt: timestamp('answered_at', { withTimezone: true }),
|
||||
|
||||
// Soft delete
|
||||
isArchived: boolean('is_archived').default(false),
|
||||
archivedAt: timestamp('archived_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
export type Question = typeof questions.$inferSelect;
|
||||
export type NewQuestion = typeof questions.$inferInsert;
|
||||
31
apps/questions/apps/backend/src/db/schema/research.schema.ts
Normal file
31
apps/questions/apps/backend/src/db/schema/research.schema.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { pgTable, uuid, text, integer, real, timestamp, jsonb } from 'drizzle-orm/pg-core';
|
||||
import { questions } from './questions.schema';
|
||||
|
||||
export const researchResults = pgTable('research_results', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
questionId: uuid('question_id')
|
||||
.notNull()
|
||||
.references(() => questions.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Research metadata
|
||||
modelId: text('model_id').notNull(),
|
||||
provider: text('provider').notNull(), // 'searxng', 'ollama', 'openrouter'
|
||||
researchDepth: text('research_depth').notNull(), // 'quick', 'standard', 'deep'
|
||||
|
||||
// Results
|
||||
summary: text('summary').notNull(),
|
||||
keyPoints: jsonb('key_points').default([]),
|
||||
followUpQuestions: text('follow_up_questions').array().default([]),
|
||||
|
||||
// Token tracking
|
||||
promptTokens: integer('prompt_tokens'),
|
||||
completionTokens: integer('completion_tokens'),
|
||||
estimatedCost: real('estimated_cost'),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
durationMs: integer('duration_ms'),
|
||||
});
|
||||
|
||||
export type ResearchResult = typeof researchResults.$inferSelect;
|
||||
export type NewResearchResult = typeof researchResults.$inferInsert;
|
||||
41
apps/questions/apps/backend/src/db/schema/sources.schema.ts
Normal file
41
apps/questions/apps/backend/src/db/schema/sources.schema.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { pgTable, uuid, text, integer, timestamp, jsonb, real } from 'drizzle-orm/pg-core';
|
||||
import { researchResults } from './research.schema';
|
||||
|
||||
export const sources = pgTable('sources', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
researchResultId: uuid('research_result_id')
|
||||
.notNull()
|
||||
.references(() => researchResults.id, { onDelete: 'cascade' }),
|
||||
|
||||
// Source metadata
|
||||
url: text('url').notNull(),
|
||||
title: text('title').notNull(),
|
||||
snippet: text('snippet'),
|
||||
domain: text('domain'),
|
||||
|
||||
// Content extraction
|
||||
extractedContent: text('extracted_content'),
|
||||
contentMarkdown: text('content_markdown'),
|
||||
wordCount: integer('word_count'),
|
||||
readingTime: integer('reading_time'), // in minutes
|
||||
|
||||
// Quality indicators
|
||||
relevanceScore: real('relevance_score'), // 0-1 score from search
|
||||
position: integer('position'), // Position in search results
|
||||
engine: text('engine'), // Which search engine found this
|
||||
|
||||
// Publication info
|
||||
author: text('author'),
|
||||
publishedDate: timestamp('published_date', { withTimezone: true }),
|
||||
siteName: text('site_name'),
|
||||
|
||||
// Additional metadata
|
||||
metadata: jsonb('metadata').default({}),
|
||||
|
||||
// Timestamps
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
extractedAt: timestamp('extracted_at', { withTimezone: true }),
|
||||
});
|
||||
|
||||
export type Source = typeof sources.$inferSelect;
|
||||
export type NewSource = typeof sources.$inferInsert;
|
||||
49
apps/questions/apps/backend/src/health/health.controller.ts
Normal file
49
apps/questions/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Controller, Get, Inject } from '@nestjs/common';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
@Inject('DATABASE_CONNECTION')
|
||||
private readonly db: NodePgDatabase,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
async check() {
|
||||
const checks = {
|
||||
database: await this.checkDatabase(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const healthy = Object.values(checks).every((v) => v === true || typeof v === 'string');
|
||||
|
||||
return {
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
liveness() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
async readiness() {
|
||||
const dbOk = await this.checkDatabase();
|
||||
return {
|
||||
status: dbOk ? 'ready' : 'not_ready',
|
||||
database: dbOk,
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDatabase(): Promise<boolean> {
|
||||
try {
|
||||
await this.db.execute(sql`SELECT 1`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
apps/questions/apps/backend/src/health/health.module.ts
Normal file
7
apps/questions/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
41
apps/questions/apps/backend/src/main.ts
Normal file
41
apps/questions/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
const port = configService.get<number>('port', 3011);
|
||||
|
||||
// Global prefix
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
// CORS
|
||||
app.enableCors({
|
||||
origin: configService.get<string[]>('cors.origins', [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:8081',
|
||||
]),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`Questions Backend running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/api/v1/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { IsString, IsOptional, IsArray, IsUUID, IsEnum } from 'class-validator';
|
||||
|
||||
export enum QuestionPriority {
|
||||
LOW = 'low',
|
||||
NORMAL = 'normal',
|
||||
HIGH = 'high',
|
||||
URGENT = 'urgent',
|
||||
}
|
||||
|
||||
export enum ResearchDepth {
|
||||
QUICK = 'quick',
|
||||
STANDARD = 'standard',
|
||||
DEEP = 'deep',
|
||||
}
|
||||
|
||||
export class CreateQuestionDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
collectionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(QuestionPriority)
|
||||
priority?: QuestionPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ResearchDepth)
|
||||
researchDepth?: ResearchDepth;
|
||||
}
|
||||
2
apps/questions/apps/backend/src/question/dto/index.ts
Normal file
2
apps/questions/apps/backend/src/question/dto/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './create-question.dto';
|
||||
export * from './update-question.dto';
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { IsString, IsOptional, IsArray, IsUUID, IsEnum } from 'class-validator';
|
||||
import { QuestionPriority, ResearchDepth } from './create-question.dto';
|
||||
|
||||
export enum QuestionStatus {
|
||||
OPEN = 'open',
|
||||
RESEARCHING = 'researching',
|
||||
ANSWERED = 'answered',
|
||||
ARCHIVED = 'archived',
|
||||
}
|
||||
|
||||
export class UpdateQuestionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
collectionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(QuestionPriority)
|
||||
priority?: QuestionPriority;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(QuestionStatus)
|
||||
status?: QuestionStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ResearchDepth)
|
||||
researchDepth?: ResearchDepth;
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { QuestionService } from './question.service';
|
||||
import { CreateQuestionDto, UpdateQuestionDto } from './dto';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
|
||||
@Controller('questions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class QuestionController {
|
||||
constructor(private readonly questionService: QuestionService) {}
|
||||
|
||||
@Post()
|
||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateQuestionDto) {
|
||||
return this.questionService.create(user.userId, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('collectionId') collectionId?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('tags') tags?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
return this.questionService.findAll(user.userId, {
|
||||
collectionId,
|
||||
status,
|
||||
search,
|
||||
tags: tags ? tags.split(',') : undefined,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
offset: offset ? parseInt(offset, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.questionService.findOne(user.userId, id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateQuestionDto,
|
||||
) {
|
||||
return this.questionService.update(user.userId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
await this.questionService.delete(user.userId, id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
async updateStatus(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('status') status: string,
|
||||
) {
|
||||
return this.questionService.updateStatus(user.userId, id, status);
|
||||
}
|
||||
}
|
||||
12
apps/questions/apps/backend/src/question/question.module.ts
Normal file
12
apps/questions/apps/backend/src/question/question.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { QuestionController } from './question.controller';
|
||||
import { QuestionService } from './question.service';
|
||||
import { DatabaseModule } from '../db/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [QuestionController],
|
||||
providers: [QuestionService],
|
||||
exports: [QuestionService],
|
||||
})
|
||||
export class QuestionModule {}
|
||||
157
apps/questions/apps/backend/src/question/question.service.ts
Normal file
157
apps/questions/apps/backend/src/question/question.service.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { eq, and, desc, isNull, ilike, or, inArray } from 'drizzle-orm';
|
||||
import { questions, Question, NewQuestion } from '../db/schema';
|
||||
import { CreateQuestionDto, UpdateQuestionDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class QuestionService {
|
||||
constructor(
|
||||
@Inject('DATABASE_CONNECTION')
|
||||
private readonly db: NodePgDatabase,
|
||||
) {}
|
||||
|
||||
async create(userId: string, dto: CreateQuestionDto): Promise<Question> {
|
||||
const newQuestion: NewQuestion = {
|
||||
userId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
collectionId: dto.collectionId,
|
||||
tags: dto.tags || [],
|
||||
priority: dto.priority || 'normal',
|
||||
researchDepth: dto.researchDepth || 'quick',
|
||||
};
|
||||
|
||||
const [created] = await this.db.insert(questions).values(newQuestion).returning();
|
||||
return created;
|
||||
}
|
||||
|
||||
async findAll(
|
||||
userId: string,
|
||||
options?: {
|
||||
collectionId?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
): Promise<{ data: Question[]; total: number }> {
|
||||
const conditions = [eq(questions.userId, userId), isNull(questions.deletedAt)];
|
||||
|
||||
if (options?.collectionId) {
|
||||
conditions.push(eq(questions.collectionId, options.collectionId));
|
||||
}
|
||||
|
||||
if (options?.status) {
|
||||
conditions.push(eq(questions.status, options.status));
|
||||
}
|
||||
|
||||
if (options?.search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(questions.title, `%${options.search}%`),
|
||||
ilike(questions.description, `%${options.search}%`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const limit = options?.limit || 20;
|
||||
const offset = options?.offset || 0;
|
||||
|
||||
const data = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(questions.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
// For total count, we need a separate query
|
||||
const allMatching = await this.db
|
||||
.select({ id: questions.id })
|
||||
.from(questions)
|
||||
.where(and(...conditions));
|
||||
|
||||
return { data, total: allMatching.length };
|
||||
}
|
||||
|
||||
async findOne(userId: string, id: string): Promise<Question> {
|
||||
const [question] = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.id, id), eq(questions.userId, userId), isNull(questions.deletedAt)));
|
||||
|
||||
if (!question) {
|
||||
throw new NotFoundException(`Question with id ${id} not found`);
|
||||
}
|
||||
|
||||
return question;
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, dto: UpdateQuestionDto): Promise<Question> {
|
||||
// First check if the question exists and belongs to user
|
||||
await this.findOne(userId, id);
|
||||
|
||||
const updateData: Partial<NewQuestion> = {
|
||||
...dto,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(questions)
|
||||
.set(updateData)
|
||||
.where(and(eq(questions.id, id), eq(questions.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(userId: string, id: string): Promise<void> {
|
||||
// Soft delete
|
||||
await this.findOne(userId, id);
|
||||
|
||||
await this.db
|
||||
.update(questions)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(eq(questions.id, id), eq(questions.userId, userId)));
|
||||
}
|
||||
|
||||
async updateStatus(userId: string, id: string, status: string): Promise<Question> {
|
||||
await this.findOne(userId, id);
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(questions)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(and(eq(questions.id, id), eq(questions.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async getByCollection(userId: string, collectionId: string): Promise<Question[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(
|
||||
and(
|
||||
eq(questions.userId, userId),
|
||||
eq(questions.collectionId, collectionId),
|
||||
isNull(questions.deletedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(questions.createdAt));
|
||||
}
|
||||
|
||||
async getByTags(userId: string, tags: string[]): Promise<Question[]> {
|
||||
// PostgreSQL array overlap query - find questions that have any of the specified tags
|
||||
const allQuestions = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.userId, userId), isNull(questions.deletedAt)))
|
||||
.orderBy(desc(questions.createdAt));
|
||||
|
||||
// Filter in memory for array overlap (Drizzle doesn't have native array overlap)
|
||||
return allQuestions.filter((q) => q.tags?.some((t) => tags.includes(t)));
|
||||
}
|
||||
}
|
||||
1
apps/questions/apps/backend/src/research/dto/index.ts
Normal file
1
apps/questions/apps/backend/src/research/dto/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './start-research.dto';
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { IsUUID, IsOptional, IsEnum, IsArray, IsString, IsNumber } from 'class-validator';
|
||||
|
||||
export enum ResearchDepth {
|
||||
QUICK = 'quick', // 5-10 sources, fast
|
||||
STANDARD = 'standard', // 15-20 sources, balanced
|
||||
DEEP = 'deep', // 30+ sources, comprehensive
|
||||
}
|
||||
|
||||
export enum SearchCategory {
|
||||
GENERAL = 'general',
|
||||
NEWS = 'news',
|
||||
SCIENCE = 'science',
|
||||
IT = 'it',
|
||||
IMAGES = 'images',
|
||||
VIDEOS = 'videos',
|
||||
}
|
||||
|
||||
export class StartResearchDto {
|
||||
@IsUUID()
|
||||
questionId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ResearchDepth)
|
||||
depth?: ResearchDepth;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsEnum(SearchCategory, { each: true })
|
||||
categories?: SearchCategory[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
engines?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
language?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
maxSources?: number;
|
||||
}
|
||||
203
apps/questions/apps/backend/src/research/mana-search.client.ts
Normal file
203
apps/questions/apps/backend/src/research/mana-search.client.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface SearchOptions {
|
||||
categories?: string[];
|
||||
engines?: string[];
|
||||
language?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
url: string;
|
||||
title: string;
|
||||
snippet?: string;
|
||||
engine: string;
|
||||
score?: number;
|
||||
publishedDate?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[];
|
||||
meta: {
|
||||
query: string;
|
||||
duration: number;
|
||||
total: number;
|
||||
cached: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExtractOptions {
|
||||
includeMarkdown?: boolean;
|
||||
includeHtml?: boolean;
|
||||
maxLength?: number;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ExtractedContent {
|
||||
title: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
publishedDate?: string;
|
||||
siteName?: string;
|
||||
text: string;
|
||||
markdown?: string;
|
||||
html?: string;
|
||||
wordCount: number;
|
||||
readingTime: number;
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
export interface ExtractResponse {
|
||||
success: boolean;
|
||||
content?: ExtractedContent;
|
||||
error?: string;
|
||||
meta: {
|
||||
url: string;
|
||||
duration: number;
|
||||
cached: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BulkExtractResponse {
|
||||
results: Array<{
|
||||
url: string;
|
||||
success: boolean;
|
||||
content?: ExtractedContent;
|
||||
error?: string;
|
||||
}>;
|
||||
meta: {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
duration: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ManaSearchClient {
|
||||
private readonly logger = new Logger(ManaSearchClient.name);
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeout: number;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>('manaSearch.url', 'http://localhost:3021');
|
||||
this.timeout = this.configService.get<number>('manaSearch.timeout', 30000);
|
||||
}
|
||||
|
||||
async search(query: string, options?: SearchOptions): Promise<SearchResponse> {
|
||||
const url = `${this.baseUrl}/api/v1/search`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
options: {
|
||||
categories: options?.categories || ['general'],
|
||||
engines: options?.engines,
|
||||
language: options?.language || 'de-DE',
|
||||
limit: options?.limit || 20,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
this.logger.error(`Search error for "${query}": ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async extract(url: string, options?: ExtractOptions): Promise<ExtractResponse> {
|
||||
const apiUrl = `${this.baseUrl}/api/v1/extract`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
options: {
|
||||
includeMarkdown: options?.includeMarkdown ?? true,
|
||||
includeHtml: options?.includeHtml ?? false,
|
||||
maxLength: options?.maxLength || 50000,
|
||||
timeout: options?.timeout || 10000,
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Extract failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
this.logger.error(`Extract error for "${url}": ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Extraction failed',
|
||||
meta: {
|
||||
url,
|
||||
duration: 0,
|
||||
cached: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async bulkExtract(urls: string[], options?: ExtractOptions): Promise<BulkExtractResponse> {
|
||||
const apiUrl = `${this.baseUrl}/api/v1/extract/bulk`;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
urls,
|
||||
options: {
|
||||
includeMarkdown: options?.includeMarkdown ?? true,
|
||||
includeHtml: options?.includeHtml ?? false,
|
||||
maxLength: options?.maxLength || 50000,
|
||||
},
|
||||
concurrency: 5,
|
||||
}),
|
||||
signal: AbortSignal.timeout(this.timeout * urls.length),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Bulk extract failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
this.logger.error(`Bulk extract error: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Controller, Get, Post, Body, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common';
|
||||
import { ResearchService } from './research.service';
|
||||
import { ManaSearchClient } from './mana-search.client';
|
||||
import { StartResearchDto } from './dto';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
|
||||
@Controller('research')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ResearchController {
|
||||
constructor(
|
||||
private readonly researchService: ResearchService,
|
||||
private readonly manaSearchClient: ManaSearchClient,
|
||||
) {}
|
||||
|
||||
@Post('start')
|
||||
async startResearch(@CurrentUser() user: CurrentUserData, @Body() dto: StartResearchDto) {
|
||||
return this.researchService.startResearch(user.userId, dto);
|
||||
}
|
||||
|
||||
@Get('question/:questionId')
|
||||
async getResearchResults(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('questionId', ParseUUIDPipe) questionId: string,
|
||||
) {
|
||||
return this.researchService.getResearchResults(user.userId, questionId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getResearchResult(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
) {
|
||||
return this.researchService.getResearchResult(user.userId, id);
|
||||
}
|
||||
|
||||
@Get('health/search')
|
||||
async checkSearchHealth() {
|
||||
const healthy = await this.manaSearchClient.healthCheck();
|
||||
return {
|
||||
service: 'mana-search',
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
};
|
||||
}
|
||||
}
|
||||
13
apps/questions/apps/backend/src/research/research.module.ts
Normal file
13
apps/questions/apps/backend/src/research/research.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ResearchController } from './research.controller';
|
||||
import { ResearchService } from './research.service';
|
||||
import { ManaSearchClient } from './mana-search.client';
|
||||
import { DatabaseModule } from '../db/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [ResearchController],
|
||||
providers: [ResearchService, ManaSearchClient],
|
||||
exports: [ResearchService, ManaSearchClient],
|
||||
})
|
||||
export class ResearchModule {}
|
||||
257
apps/questions/apps/backend/src/research/research.service.ts
Normal file
257
apps/questions/apps/backend/src/research/research.service.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { Injectable, Inject, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import {
|
||||
questions,
|
||||
researchResults,
|
||||
sources,
|
||||
ResearchResult,
|
||||
NewResearchResult,
|
||||
NewSource,
|
||||
} from '../db/schema';
|
||||
import { ManaSearchClient, SearchResult } from './mana-search.client';
|
||||
import { StartResearchDto, ResearchDepth } from './dto';
|
||||
|
||||
interface DepthConfig {
|
||||
maxSources: number;
|
||||
extractContent: boolean;
|
||||
searchCategories: string[];
|
||||
}
|
||||
|
||||
const DEPTH_CONFIG: Record<ResearchDepth, DepthConfig> = {
|
||||
[ResearchDepth.QUICK]: {
|
||||
maxSources: 5,
|
||||
extractContent: false,
|
||||
searchCategories: ['general'],
|
||||
},
|
||||
[ResearchDepth.STANDARD]: {
|
||||
maxSources: 15,
|
||||
extractContent: true,
|
||||
searchCategories: ['general', 'news'],
|
||||
},
|
||||
[ResearchDepth.DEEP]: {
|
||||
maxSources: 30,
|
||||
extractContent: true,
|
||||
searchCategories: ['general', 'news', 'science', 'it'],
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ResearchService {
|
||||
private readonly logger = new Logger(ResearchService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('DATABASE_CONNECTION')
|
||||
private readonly db: NodePgDatabase,
|
||||
private readonly manaSearchClient: ManaSearchClient,
|
||||
) {}
|
||||
|
||||
async startResearch(userId: string, dto: StartResearchDto): Promise<ResearchResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Get the question
|
||||
const [question] = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.id, dto.questionId), eq(questions.userId, userId)));
|
||||
|
||||
if (!question) {
|
||||
throw new NotFoundException(`Question with id ${dto.questionId} not found`);
|
||||
}
|
||||
|
||||
// Check if question is already being researched
|
||||
if (question.status === 'researching') {
|
||||
throw new BadRequestException('Research is already in progress for this question');
|
||||
}
|
||||
|
||||
// Update question status
|
||||
await this.db
|
||||
.update(questions)
|
||||
.set({ status: 'researching', updatedAt: new Date() })
|
||||
.where(eq(questions.id, dto.questionId));
|
||||
|
||||
try {
|
||||
const depth = dto.depth || (question.researchDepth as ResearchDepth) || ResearchDepth.QUICK;
|
||||
const config = DEPTH_CONFIG[depth];
|
||||
|
||||
// Perform search
|
||||
const searchResponse = await this.manaSearchClient.search(question.title, {
|
||||
categories: dto.categories || config.searchCategories,
|
||||
engines: dto.engines,
|
||||
language: dto.language || 'de-DE',
|
||||
limit: dto.maxSources || config.maxSources,
|
||||
});
|
||||
|
||||
// Extract content for sources if depth allows
|
||||
let extractedSources: Array<SearchResult & { extractedContent?: string; markdown?: string }> =
|
||||
searchResponse.results;
|
||||
|
||||
if (config.extractContent && searchResponse.results.length > 0) {
|
||||
const urls = searchResponse.results.slice(0, config.maxSources).map((r) => r.url);
|
||||
const bulkExtract = await this.manaSearchClient.bulkExtract(urls);
|
||||
|
||||
extractedSources = searchResponse.results.map((result) => {
|
||||
const extracted = bulkExtract.results.find((e) => e.url === result.url);
|
||||
return {
|
||||
...result,
|
||||
extractedContent: extracted?.success ? extracted.content?.text : undefined,
|
||||
markdown: extracted?.success ? extracted.content?.markdown : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Generate summary from results
|
||||
const summary = this.generateSummary(question.title, extractedSources);
|
||||
const keyPoints = this.extractKeyPoints(extractedSources);
|
||||
const followUpQuestions = this.generateFollowUpQuestions(question.title, extractedSources);
|
||||
|
||||
// Save research result
|
||||
const newResearchResult: NewResearchResult = {
|
||||
questionId: dto.questionId,
|
||||
modelId: 'mana-search',
|
||||
provider: 'searxng',
|
||||
researchDepth: depth,
|
||||
summary,
|
||||
keyPoints,
|
||||
followUpQuestions,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
|
||||
const [researchResult] = await this.db
|
||||
.insert(researchResults)
|
||||
.values(newResearchResult)
|
||||
.returning();
|
||||
|
||||
// Save sources
|
||||
if (extractedSources.length > 0) {
|
||||
const sourcesToInsert: NewSource[] = extractedSources.map((source, index) => ({
|
||||
researchResultId: researchResult.id,
|
||||
url: source.url,
|
||||
title: source.title,
|
||||
snippet: source.snippet,
|
||||
domain: new URL(source.url).hostname,
|
||||
extractedContent: source.extractedContent,
|
||||
contentMarkdown: source.markdown,
|
||||
relevanceScore: source.score,
|
||||
position: index + 1,
|
||||
engine: source.engine,
|
||||
}));
|
||||
|
||||
await this.db.insert(sources).values(sourcesToInsert);
|
||||
}
|
||||
|
||||
// Update question status
|
||||
await this.db
|
||||
.update(questions)
|
||||
.set({ status: 'answered', updatedAt: new Date() })
|
||||
.where(eq(questions.id, dto.questionId));
|
||||
|
||||
return researchResult;
|
||||
} catch (error) {
|
||||
// Reset question status on error
|
||||
await this.db
|
||||
.update(questions)
|
||||
.set({ status: 'open', updatedAt: new Date() })
|
||||
.where(eq(questions.id, dto.questionId));
|
||||
|
||||
this.logger.error(`Research failed for question ${dto.questionId}: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getResearchResults(userId: string, questionId: string): Promise<ResearchResult[]> {
|
||||
// Verify user owns the question
|
||||
const [question] = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.id, questionId), eq(questions.userId, userId)));
|
||||
|
||||
if (!question) {
|
||||
throw new NotFoundException(`Question with id ${questionId} not found`);
|
||||
}
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(researchResults)
|
||||
.where(eq(researchResults.questionId, questionId))
|
||||
.orderBy(desc(researchResults.createdAt));
|
||||
}
|
||||
|
||||
async getResearchResult(
|
||||
userId: string,
|
||||
resultId: string,
|
||||
): Promise<ResearchResult & { sources: any[] }> {
|
||||
const [result] = await this.db
|
||||
.select()
|
||||
.from(researchResults)
|
||||
.where(eq(researchResults.id, resultId));
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException(`Research result with id ${resultId} not found`);
|
||||
}
|
||||
|
||||
// Verify user owns the question
|
||||
const [question] = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.id, result.questionId), eq(questions.userId, userId)));
|
||||
|
||||
if (!question) {
|
||||
throw new NotFoundException('Research result not found');
|
||||
}
|
||||
|
||||
// Get sources
|
||||
const resultSources = await this.db
|
||||
.select()
|
||||
.from(sources)
|
||||
.where(eq(sources.researchResultId, resultId))
|
||||
.orderBy(sources.position);
|
||||
|
||||
return {
|
||||
...result,
|
||||
sources: resultSources,
|
||||
};
|
||||
}
|
||||
|
||||
private generateSummary(
|
||||
question: string,
|
||||
sources: Array<SearchResult & { extractedContent?: string }>,
|
||||
): string {
|
||||
if (sources.length === 0) {
|
||||
return 'No relevant sources found for this question.';
|
||||
}
|
||||
|
||||
// Simple summary from snippets (in production, this would use an LLM)
|
||||
const snippets = sources
|
||||
.filter((s) => s.snippet || s.extractedContent)
|
||||
.slice(0, 5)
|
||||
.map((s) => s.snippet || s.extractedContent?.substring(0, 500))
|
||||
.join('\n\n');
|
||||
|
||||
return `Research found ${sources.length} relevant sources for: "${question}"\n\nKey findings:\n${snippets}`;
|
||||
}
|
||||
|
||||
private extractKeyPoints(
|
||||
sources: Array<SearchResult & { extractedContent?: string }>,
|
||||
): string[] {
|
||||
// Extract key points from titles and snippets
|
||||
return sources
|
||||
.filter((s) => s.title)
|
||||
.slice(0, 5)
|
||||
.map((s) => s.title);
|
||||
}
|
||||
|
||||
private generateFollowUpQuestions(
|
||||
question: string,
|
||||
sources: Array<SearchResult>,
|
||||
): string[] {
|
||||
// Generate related questions (in production, this would use an LLM)
|
||||
const baseQuestions = [
|
||||
`What are the main challenges related to ${question}?`,
|
||||
`What are the latest developments in ${question}?`,
|
||||
`How does ${question} compare to alternatives?`,
|
||||
];
|
||||
|
||||
return baseQuestions;
|
||||
}
|
||||
}
|
||||
35
apps/questions/apps/backend/src/source/source.controller.ts
Normal file
35
apps/questions/apps/backend/src/source/source.controller.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Controller, Get, Param, UseGuards, ParseUUIDPipe } from '@nestjs/common';
|
||||
import { SourceService } from './source.service';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
|
||||
@Controller('sources')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SourceController {
|
||||
constructor(private readonly sourceService: SourceService) {}
|
||||
|
||||
@Get('research/:researchResultId')
|
||||
async findByResearchResult(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('researchResultId', ParseUUIDPipe) researchResultId: string,
|
||||
) {
|
||||
return this.sourceService.findByResearchResult(user.userId, researchResultId);
|
||||
}
|
||||
|
||||
@Get('question/:questionId')
|
||||
async findByQuestion(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('questionId', ParseUUIDPipe) questionId: string,
|
||||
) {
|
||||
return this.sourceService.findByQuestion(user.userId, questionId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.sourceService.findOne(user.userId, id);
|
||||
}
|
||||
|
||||
@Get(':id/content')
|
||||
async getContent(@CurrentUser() user: CurrentUserData, @Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.sourceService.getContent(user.userId, id);
|
||||
}
|
||||
}
|
||||
12
apps/questions/apps/backend/src/source/source.module.ts
Normal file
12
apps/questions/apps/backend/src/source/source.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { SourceController } from './source.controller';
|
||||
import { SourceService } from './source.service';
|
||||
import { DatabaseModule } from '../db/database.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [SourceController],
|
||||
providers: [SourceService],
|
||||
exports: [SourceService],
|
||||
})
|
||||
export class SourceModule {}
|
||||
99
apps/questions/apps/backend/src/source/source.service.ts
Normal file
99
apps/questions/apps/backend/src/source/source.service.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { questions, researchResults, sources, Source } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class SourceService {
|
||||
constructor(
|
||||
@Inject('DATABASE_CONNECTION')
|
||||
private readonly db: NodePgDatabase,
|
||||
) {}
|
||||
|
||||
async findByResearchResult(userId: string, researchResultId: string): Promise<Source[]> {
|
||||
// Verify user owns the research result
|
||||
await this.verifyOwnership(userId, researchResultId);
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(sources)
|
||||
.where(eq(sources.researchResultId, researchResultId))
|
||||
.orderBy(sources.position);
|
||||
}
|
||||
|
||||
async findOne(userId: string, id: string): Promise<Source> {
|
||||
const [source] = await this.db.select().from(sources).where(eq(sources.id, id));
|
||||
|
||||
if (!source) {
|
||||
throw new NotFoundException(`Source with id ${id} not found`);
|
||||
}
|
||||
|
||||
// Verify user owns the source via research result
|
||||
await this.verifyOwnership(userId, source.researchResultId);
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
async findByQuestion(userId: string, questionId: string): Promise<Source[]> {
|
||||
// Verify user owns the question
|
||||
const [question] = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.id, questionId), eq(questions.userId, userId)));
|
||||
|
||||
if (!question) {
|
||||
throw new NotFoundException(`Question with id ${questionId} not found`);
|
||||
}
|
||||
|
||||
// Get all sources from all research results for this question
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(researchResults)
|
||||
.where(eq(researchResults.questionId, questionId));
|
||||
|
||||
if (results.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allSources: Source[] = [];
|
||||
for (const result of results) {
|
||||
const resultSources = await this.db
|
||||
.select()
|
||||
.from(sources)
|
||||
.where(eq(sources.researchResultId, result.id))
|
||||
.orderBy(sources.position);
|
||||
allSources.push(...resultSources);
|
||||
}
|
||||
|
||||
return allSources;
|
||||
}
|
||||
|
||||
async getContent(userId: string, id: string): Promise<{ text: string; markdown?: string }> {
|
||||
const source = await this.findOne(userId, id);
|
||||
|
||||
return {
|
||||
text: source.extractedContent || source.snippet || '',
|
||||
markdown: source.contentMarkdown || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async verifyOwnership(userId: string, researchResultId: string): Promise<void> {
|
||||
const [result] = await this.db
|
||||
.select()
|
||||
.from(researchResults)
|
||||
.where(eq(researchResults.id, researchResultId));
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException('Research result not found');
|
||||
}
|
||||
|
||||
const [question] = await this.db
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(and(eq(questions.id, result.questionId), eq(questions.userId, userId)));
|
||||
|
||||
if (!question) {
|
||||
throw new NotFoundException('Source not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
25
apps/questions/apps/backend/tsconfig.json
Normal file
25
apps/questions/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
7
apps/questions/apps/web/.env.example
Normal file
7
apps/questions/apps/web/.env.example
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Questions Web App Environment Variables
|
||||
|
||||
# Backend API URL
|
||||
PUBLIC_BACKEND_URL=http://localhost:3011
|
||||
|
||||
# Mana Core Auth URL
|
||||
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
48
apps/questions/apps/web/package.json
Normal file
48
apps/questions/apps/web/package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "@questions/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-svelte": "^0.556.0",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
185
apps/questions/apps/web/src/app.css
Normal file
185
apps/questions/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
@import 'tailwindcss';
|
||||
@import '@manacore/shared-tailwind/themes.css';
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../../packages/shared-theme-ui/src/pages";
|
||||
|
||||
:root {
|
||||
/* Questions App - Indigo/Blue Theme */
|
||||
--color-primary: #6366f1;
|
||||
--color-primary-hover: #4f46e5;
|
||||
--color-primary-light: #818cf8;
|
||||
--color-primary-dark: #4338ca;
|
||||
|
||||
--color-secondary: #e0e7ff;
|
||||
--color-secondary-hover: #c7d2fe;
|
||||
|
||||
--color-accent: #8b5cf6;
|
||||
--color-accent-hover: #7c3aed;
|
||||
|
||||
/* Question status colors */
|
||||
--color-status-open: #6b7280;
|
||||
--color-status-researching: #3b82f6;
|
||||
--color-status-answered: #22c55e;
|
||||
--color-status-archived: #9ca3af;
|
||||
|
||||
/* Research depth colors */
|
||||
--color-depth-quick: #22c55e;
|
||||
--color-depth-standard: #eab308;
|
||||
--color-depth-deep: #8b5cf6;
|
||||
|
||||
/* Priority colors */
|
||||
--color-priority-low: #6b7280;
|
||||
--color-priority-normal: #3b82f6;
|
||||
--color-priority-high: #f97316;
|
||||
--color-priority-urgent: #ef4444;
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
:root.dark {
|
||||
--color-secondary: #1e1b4b;
|
||||
--color-secondary-hover: #2e1065;
|
||||
}
|
||||
|
||||
/* Question card transitions */
|
||||
.question-card {
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.question-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Collection item styling */
|
||||
.collection-item {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.collection-item:hover {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.collection-item.active {
|
||||
background-color: var(--color-secondary);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
/* Research progress animation */
|
||||
.research-progress {
|
||||
background: linear-gradient(90deg, var(--color-primary) 0%, var(--color-accent) 100%);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Source card */
|
||||
.source-card {
|
||||
transition: all 0.15s ease;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.source-card:hover {
|
||||
border-left-color: var(--color-primary);
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
|
||||
/* Answer styling */
|
||||
.answer-accepted {
|
||||
border: 2px solid var(--color-status-answered);
|
||||
background-color: rgba(34, 197, 94, 0.05);
|
||||
}
|
||||
|
||||
/* Tag badges */
|
||||
.tag-badge {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tag-badge:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Depth indicator */
|
||||
.depth-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.depth-quick {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: var(--color-depth-quick);
|
||||
}
|
||||
|
||||
.depth-standard {
|
||||
background-color: rgba(234, 179, 8, 0.1);
|
||||
color: var(--color-depth-standard);
|
||||
}
|
||||
|
||||
.depth-deep {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
color: var(--color-depth-deep);
|
||||
}
|
||||
|
||||
/* Markdown content styling */
|
||||
.markdown-content {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin-left: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background-color: var(--color-secondary);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background-color: var(--color-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 3px solid var(--color-primary);
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
13
apps/questions/apps/web/src/app.d.ts
vendored
Normal file
13
apps/questions/apps/web/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
14
apps/questions/apps/web/src/app.html
Normal file
14
apps/questions/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<meta name="description" content="Questions - AI-powered research assistant" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
33
apps/questions/apps/web/src/lib/api/answers.ts
Normal file
33
apps/questions/apps/web/src/lib/api/answers.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { apiClient } from './client';
|
||||
import type { Answer } from '$lib/types';
|
||||
|
||||
export interface RateAnswerDto {
|
||||
rating: number;
|
||||
feedback?: string;
|
||||
}
|
||||
|
||||
export const answersApi = {
|
||||
async getByQuestion(questionId: string): Promise<Answer[]> {
|
||||
return apiClient.get<Answer[]>(`/api/v1/answers/question/${questionId}`);
|
||||
},
|
||||
|
||||
async getAccepted(questionId: string): Promise<Answer | null> {
|
||||
return apiClient.get<Answer | null>(`/api/v1/answers/question/${questionId}/accepted`);
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<Answer> {
|
||||
return apiClient.get<Answer>(`/api/v1/answers/${id}`);
|
||||
},
|
||||
|
||||
async rate(id: string, data: RateAnswerDto): Promise<Answer> {
|
||||
return apiClient.post<Answer>(`/api/v1/answers/${id}/rate`, data);
|
||||
},
|
||||
|
||||
async accept(id: string, isAccepted: boolean): Promise<Answer> {
|
||||
return apiClient.post<Answer>(`/api/v1/answers/${id}/accept`, { isAccepted });
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/answers/${id}`);
|
||||
},
|
||||
};
|
||||
97
apps/questions/apps/web/src/lib/api/client.ts
Normal file
97
apps/questions/apps/web/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { PUBLIC_BACKEND_URL } from '$env/static/public';
|
||||
|
||||
interface ApiOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ApiError {
|
||||
message: string;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend URL, preferring runtime-injected value in browser
|
||||
*/
|
||||
function getBackendUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const runtimeUrl = (window as Window & { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
if (runtimeUrl) {
|
||||
return runtimeUrl;
|
||||
}
|
||||
}
|
||||
return PUBLIC_BACKEND_URL || 'http://localhost:3011';
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private accessToken: string | null = null;
|
||||
|
||||
private get baseUrl(): string {
|
||||
return getBackendUrl();
|
||||
}
|
||||
|
||||
setAccessToken(token: string | null) {
|
||||
this.accessToken = token;
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
async fetch<T>(endpoint: string, options: ApiOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, headers = {} } = options;
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (this.accessToken) {
|
||||
requestHeaders['Authorization'] = `Bearer ${this.accessToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'An error occurred';
|
||||
try {
|
||||
const errorData = (await response.json()) as ApiError;
|
||||
errorMessage = errorData.message || errorMessage;
|
||||
} catch {
|
||||
errorMessage = response.statusText || errorMessage;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {} as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
get<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'GET', headers });
|
||||
}
|
||||
|
||||
post<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'POST', body, headers });
|
||||
}
|
||||
|
||||
put<T>(endpoint: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'PUT', body, headers });
|
||||
}
|
||||
|
||||
delete<T>(endpoint: string, headers?: Record<string, string>): Promise<T> {
|
||||
return this.fetch<T>(endpoint, { method: 'DELETE', headers });
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
32
apps/questions/apps/web/src/lib/api/collections.ts
Normal file
32
apps/questions/apps/web/src/lib/api/collections.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { apiClient } from './client';
|
||||
import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types';
|
||||
|
||||
export const collectionsApi = {
|
||||
async getAll(): Promise<Collection[]> {
|
||||
return apiClient.get<Collection[]>('/api/v1/collections');
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<Collection> {
|
||||
return apiClient.get<Collection>(`/api/v1/collections/${id}`);
|
||||
},
|
||||
|
||||
async getDefault(): Promise<Collection | null> {
|
||||
return apiClient.get<Collection | null>('/api/v1/collections/default');
|
||||
},
|
||||
|
||||
async create(data: CreateCollectionDto): Promise<Collection> {
|
||||
return apiClient.post<Collection>('/api/v1/collections', data);
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateCollectionDto): Promise<Collection> {
|
||||
return apiClient.put<Collection>(`/api/v1/collections/${id}`, data);
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/collections/${id}`);
|
||||
},
|
||||
|
||||
async reorder(orderedIds: string[]): Promise<void> {
|
||||
await apiClient.post('/api/v1/collections/reorder', { orderedIds });
|
||||
},
|
||||
};
|
||||
6
apps/questions/apps/web/src/lib/api/index.ts
Normal file
6
apps/questions/apps/web/src/lib/api/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { apiClient } from './client';
|
||||
export { questionsApi } from './questions';
|
||||
export { collectionsApi } from './collections';
|
||||
export { researchApi } from './research';
|
||||
export { answersApi } from './answers';
|
||||
export { sourcesApi } from './sources';
|
||||
53
apps/questions/apps/web/src/lib/api/questions.ts
Normal file
53
apps/questions/apps/web/src/lib/api/questions.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { apiClient } from './client';
|
||||
import type {
|
||||
Question,
|
||||
CreateQuestionDto,
|
||||
UpdateQuestionDto,
|
||||
PaginatedResponse,
|
||||
} from '$lib/types';
|
||||
|
||||
export interface QuestionFilters {
|
||||
collectionId?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export const questionsApi = {
|
||||
async getAll(filters?: QuestionFilters): Promise<PaginatedResponse<Question>> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.collectionId) params.set('collectionId', filters.collectionId);
|
||||
if (filters?.status) params.set('status', filters.status);
|
||||
if (filters?.search) params.set('search', filters.search);
|
||||
if (filters?.tags?.length) params.set('tags', filters.tags.join(','));
|
||||
if (filters?.limit) params.set('limit', filters.limit.toString());
|
||||
if (filters?.offset) params.set('offset', filters.offset.toString());
|
||||
|
||||
const query = params.toString();
|
||||
return apiClient.get<PaginatedResponse<Question>>(
|
||||
`/api/v1/questions${query ? `?${query}` : ''}`,
|
||||
);
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<Question> {
|
||||
return apiClient.get<Question>(`/api/v1/questions/${id}`);
|
||||
},
|
||||
|
||||
async create(data: CreateQuestionDto): Promise<Question> {
|
||||
return apiClient.post<Question>('/api/v1/questions', data);
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateQuestionDto): Promise<Question> {
|
||||
return apiClient.put<Question>(`/api/v1/questions/${id}`, data);
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/v1/questions/${id}`);
|
||||
},
|
||||
|
||||
async updateStatus(id: string, status: string): Promise<Question> {
|
||||
return apiClient.put<Question>(`/api/v1/questions/${id}/status`, { status });
|
||||
},
|
||||
};
|
||||
20
apps/questions/apps/web/src/lib/api/research.ts
Normal file
20
apps/questions/apps/web/src/lib/api/research.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { apiClient } from './client';
|
||||
import type { ResearchResult, StartResearchDto } from '$lib/types';
|
||||
|
||||
export const researchApi = {
|
||||
async start(data: StartResearchDto): Promise<ResearchResult> {
|
||||
return apiClient.post<ResearchResult>('/api/v1/research/start', data);
|
||||
},
|
||||
|
||||
async getByQuestion(questionId: string): Promise<ResearchResult[]> {
|
||||
return apiClient.get<ResearchResult[]>(`/api/v1/research/question/${questionId}`);
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<ResearchResult> {
|
||||
return apiClient.get<ResearchResult>(`/api/v1/research/${id}`);
|
||||
},
|
||||
|
||||
async checkHealth(): Promise<{ service: string; status: string }> {
|
||||
return apiClient.get('/api/v1/research/health/search');
|
||||
},
|
||||
};
|
||||
20
apps/questions/apps/web/src/lib/api/sources.ts
Normal file
20
apps/questions/apps/web/src/lib/api/sources.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { apiClient } from './client';
|
||||
import type { Source } from '$lib/types';
|
||||
|
||||
export const sourcesApi = {
|
||||
async getByResearchResult(researchResultId: string): Promise<Source[]> {
|
||||
return apiClient.get<Source[]>(`/api/v1/sources/research/${researchResultId}`);
|
||||
},
|
||||
|
||||
async getByQuestion(questionId: string): Promise<Source[]> {
|
||||
return apiClient.get<Source[]>(`/api/v1/sources/question/${questionId}`);
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<Source> {
|
||||
return apiClient.get<Source>(`/api/v1/sources/${id}`);
|
||||
},
|
||||
|
||||
async getContent(id: string): Promise<{ text: string; markdown?: string }> {
|
||||
return apiClient.get(`/api/v1/sources/${id}/content`);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
<script lang="ts">
|
||||
import { X } from 'lucide-svelte';
|
||||
import { collectionsStore } from '$lib/stores';
|
||||
import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
collection?: Collection | null;
|
||||
onClose: () => void;
|
||||
onSave: (collection: Collection) => void;
|
||||
}
|
||||
|
||||
let { collection = null, onClose, onSave }: Props = $props();
|
||||
|
||||
let name = $state(collection?.name || '');
|
||||
let description = $state(collection?.description || '');
|
||||
let color = $state(collection?.color || '#6366f1');
|
||||
let icon = $state(collection?.icon || 'folder');
|
||||
let isDefault = $state(collection?.isDefault || false);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const colors = [
|
||||
'#6366f1', // Indigo
|
||||
'#8b5cf6', // Violet
|
||||
'#ec4899', // Pink
|
||||
'#ef4444', // Red
|
||||
'#f97316', // Orange
|
||||
'#eab308', // Yellow
|
||||
'#22c55e', // Green
|
||||
'#14b8a6', // Teal
|
||||
'#3b82f6', // Blue
|
||||
'#6b7280', // Gray
|
||||
];
|
||||
|
||||
const icons = [
|
||||
'folder',
|
||||
'star',
|
||||
'heart',
|
||||
'bookmark',
|
||||
'lightbulb',
|
||||
'rocket',
|
||||
'code',
|
||||
'book',
|
||||
'briefcase',
|
||||
'globe',
|
||||
];
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
error = 'Name is required';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
let saved: Collection | null;
|
||||
|
||||
if (collection) {
|
||||
const data: UpdateCollectionDto = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
color,
|
||||
icon,
|
||||
isDefault,
|
||||
};
|
||||
saved = await collectionsStore.update(collection.id, data);
|
||||
} else {
|
||||
const data: CreateCollectionDto = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
color,
|
||||
icon,
|
||||
isDefault,
|
||||
};
|
||||
saved = await collectionsStore.create(data);
|
||||
}
|
||||
|
||||
if (saved) {
|
||||
onSave(saved);
|
||||
} else {
|
||||
error = collectionsStore.error || 'Failed to save collection';
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to save collection';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div class="w-full max-w-md rounded-xl bg-card shadow-xl" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-foreground">
|
||||
{collection ? 'Edit Collection' : 'New Collection'}
|
||||
</h2>
|
||||
<button onclick={onClose} class="rounded-lg p-2 text-muted-foreground hover:bg-secondary">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form onsubmit={handleSubmit} class="p-6">
|
||||
{#if error}
|
||||
<div class="mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Name -->
|
||||
<div class="mb-4">
|
||||
<label for="name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={name}
|
||||
placeholder="Collection name"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-4">
|
||||
<label for="description" class="mb-1 block text-sm font-medium text-foreground"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Optional description"
|
||||
rows="2"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-foreground">Color</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each colors as c}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (color = c)}
|
||||
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {color === c
|
||||
? 'border-foreground scale-110'
|
||||
: 'border-transparent'}"
|
||||
style="background-color: {c}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-foreground">Icon</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each icons as i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (icon = i)}
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg border-2 text-sm transition-all {icon ===
|
||||
i
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'}"
|
||||
>
|
||||
{i.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default -->
|
||||
<div class="mb-6">
|
||||
<label class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isDefault}
|
||||
class="h-5 w-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<span class="text-foreground">Set as default collection</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-lg border border-border px-4 py-2 font-medium text-foreground hover:bg-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="flex-1 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary-hover disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : collection ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
39
apps/questions/apps/web/src/lib/components/ErrorAlert.svelte
Normal file
39
apps/questions/apps/web/src/lib/components/ErrorAlert.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import { AlertCircle, X, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
onDismiss?: () => void;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
let { message, onDismiss, onRetry }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle class="mt-0.5 h-5 w-5 flex-shrink-0 text-destructive" />
|
||||
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-destructive">Error</p>
|
||||
<p class="mt-1 text-sm text-foreground">{message}</p>
|
||||
|
||||
{#if onRetry}
|
||||
<button
|
||||
onclick={onRetry}
|
||||
class="mt-2 inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
Try again
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if onDismiss}
|
||||
<button onclick={onDismiss} class="text-muted-foreground hover:text-foreground">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
3
apps/questions/apps/web/src/lib/components/index.ts
Normal file
3
apps/questions/apps/web/src/lib/components/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as CollectionModal } from './CollectionModal.svelte';
|
||||
export { default as ErrorAlert } from './ErrorAlert.svelte';
|
||||
export * from './skeletons';
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<div class="flex min-h-screen animate-pulse">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 border-r border-border bg-card">
|
||||
<!-- Header -->
|
||||
<div class="flex h-16 items-center border-b border-border px-4">
|
||||
<div class="h-6 w-28 rounded bg-muted"></div>
|
||||
</div>
|
||||
|
||||
<!-- New Question Button -->
|
||||
<div class="p-4">
|
||||
<div class="h-10 w-full rounded-lg bg-muted"></div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="space-y-1 px-2">
|
||||
<div class="h-10 w-full rounded-lg bg-muted"></div>
|
||||
|
||||
<div class="my-4 px-3">
|
||||
<div class="h-3 w-20 rounded bg-muted"></div>
|
||||
</div>
|
||||
|
||||
{#each Array(4) as _}
|
||||
<div class="h-10 w-full rounded-lg bg-muted"></div>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 p-6">
|
||||
<div class="mb-6">
|
||||
<div class="h-8 w-48 rounded bg-muted"></div>
|
||||
<div class="mt-2 h-4 w-32 rounded bg-muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 flex gap-4">
|
||||
<div class="h-10 flex-1 rounded-lg bg-muted"></div>
|
||||
<div class="h-10 w-32 rounded-lg bg-muted"></div>
|
||||
<div class="h-10 w-24 rounded-lg bg-muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _}
|
||||
<div class="h-24 rounded-xl bg-muted"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
<div class="p-6 animate-pulse">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="mb-4 h-4 w-32 rounded bg-muted"></div>
|
||||
<div class="h-8 w-3/4 rounded bg-muted"></div>
|
||||
<div class="mt-2 h-5 w-1/2 rounded bg-muted"></div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<div class="h-6 w-24 rounded-full bg-muted"></div>
|
||||
<div class="h-6 w-20 rounded-full bg-muted"></div>
|
||||
<div class="h-6 w-16 rounded-full bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Research Results -->
|
||||
<div class="mb-8">
|
||||
<div class="mb-4 h-6 w-40 rounded bg-muted"></div>
|
||||
<div class="rounded-xl border border-border bg-card p-6">
|
||||
<div class="mb-4 h-5 w-24 rounded bg-muted"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-4 w-full rounded bg-muted"></div>
|
||||
<div class="h-4 w-full rounded bg-muted"></div>
|
||||
<div class="h-4 w-3/4 rounded bg-muted"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 mb-4 h-5 w-28 rounded bg-muted"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-4 w-2/3 rounded bg-muted"></div>
|
||||
<div class="h-4 w-1/2 rounded bg-muted"></div>
|
||||
<div class="h-4 w-3/4 rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sources -->
|
||||
<div>
|
||||
<div class="mb-4 h-6 w-32 rounded bg-muted"></div>
|
||||
<div class="space-y-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="rounded-lg border border-border bg-card p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="h-5 w-3/4 rounded bg-muted"></div>
|
||||
<div class="mt-1 h-4 w-1/4 rounded bg-muted"></div>
|
||||
<div class="mt-2 h-4 w-full rounded bg-muted"></div>
|
||||
<div class="mt-3 flex gap-4">
|
||||
<div class="h-4 w-16 rounded bg-muted"></div>
|
||||
<div class="h-4 w-20 rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
let { count = 5 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each Array(count) as _, i}
|
||||
<div class="rounded-xl border border-border bg-card p-4 animate-pulse">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Status Icon -->
|
||||
<div class="mt-1">
|
||||
<div class="h-5 w-5 rounded-full bg-muted"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1">
|
||||
<div class="h-5 w-3/4 rounded bg-muted"></div>
|
||||
<div class="mt-2 h-4 w-1/2 rounded bg-muted"></div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<div class="h-5 w-16 rounded-full bg-muted"></div>
|
||||
<div class="h-5 w-16 rounded-full bg-muted"></div>
|
||||
<div class="h-4 w-20 rounded bg-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { default as QuestionSkeleton } from './QuestionSkeleton.svelte';
|
||||
export { default as QuestionDetailSkeleton } from './QuestionDetailSkeleton.svelte';
|
||||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
186
apps/questions/apps/web/src/lib/stores/auth.svelte.ts
Normal file
186
apps/questions/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Uses Mana Core Auth
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth } from '@manacore/shared-auth';
|
||||
import type { UserData } from '@manacore/shared-auth';
|
||||
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
return injectedUrl || 'http://localhost:3001';
|
||||
}
|
||||
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
}
|
||||
|
||||
function getBackendUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
return injectedUrl || 'http://localhost:3011';
|
||||
}
|
||||
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3011';
|
||||
}
|
||||
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const auth = initializeWebAuth({
|
||||
baseUrl: getAuthUrl(),
|
||||
backendUrl: getBackendUrl(),
|
||||
});
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
}
|
||||
return _authService;
|
||||
}
|
||||
|
||||
function getTokenManager() {
|
||||
if (!browser) return null;
|
||||
getAuthService();
|
||||
return _tokenManager;
|
||||
}
|
||||
|
||||
let user = $state<UserData | null>(null);
|
||||
let loading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const authStore = {
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
}
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
user = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Login failed' };
|
||||
}
|
||||
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
}
|
||||
|
||||
if (result.needsVerification) {
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
const signInResult = await this.signIn(email, password);
|
||||
return { ...signInResult, needsVerification: false };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage, needsVerification: false };
|
||||
}
|
||||
},
|
||||
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.signOut();
|
||||
user = null;
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
user = null;
|
||||
}
|
||||
},
|
||||
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.forgotPassword(email);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Password reset failed' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokenManager = getTokenManager();
|
||||
if (!tokenManager) {
|
||||
return null;
|
||||
}
|
||||
return await tokenManager.getValidToken();
|
||||
},
|
||||
};
|
||||
119
apps/questions/apps/web/src/lib/stores/collections.svelte.ts
Normal file
119
apps/questions/apps/web/src/lib/stores/collections.svelte.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Collections Store - Manages collections state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { collectionsApi } from '$lib/api/collections';
|
||||
import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types';
|
||||
|
||||
let collections = $state<Collection[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedId = $state<string | null>(null);
|
||||
|
||||
export const collectionsStore = {
|
||||
get collections() {
|
||||
return collections;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get selectedId() {
|
||||
return selectedId;
|
||||
},
|
||||
get selected() {
|
||||
return selectedId ? collections.find((c) => c.id === selectedId) : null;
|
||||
},
|
||||
|
||||
async load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
collections = await collectionsApi.getAll();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load collections';
|
||||
collections = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async create(data: CreateCollectionDto): Promise<Collection | null> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const collection = await collectionsApi.create(data);
|
||||
collections = [...collections, collection];
|
||||
return collection;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create collection';
|
||||
return null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateCollectionDto): Promise<Collection | null> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const updated = await collectionsApi.update(id, data);
|
||||
collections = collections.map((c) => (c.id === id ? updated : c));
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update collection';
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await collectionsApi.delete(id);
|
||||
collections = collections.filter((c) => c.id !== id);
|
||||
if (selectedId === id) {
|
||||
selectedId = null;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete collection';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async reorder(orderedIds: string[]): Promise<boolean> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await collectionsApi.reorder(orderedIds);
|
||||
// Reorder local state
|
||||
const reordered = orderedIds
|
||||
.map((id) => collections.find((c) => c.id === id))
|
||||
.filter((c): c is Collection => c !== undefined);
|
||||
collections = reordered;
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder collections';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
select(id: string | null) {
|
||||
selectedId = id;
|
||||
},
|
||||
|
||||
getById(id: string): Collection | undefined {
|
||||
return collections.find((c) => c.id === id);
|
||||
},
|
||||
|
||||
clear() {
|
||||
collections = [];
|
||||
error = null;
|
||||
selectedId = null;
|
||||
},
|
||||
};
|
||||
4
apps/questions/apps/web/src/lib/stores/index.ts
Normal file
4
apps/questions/apps/web/src/lib/stores/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { authStore } from './auth.svelte';
|
||||
export { questionsStore } from './questions.svelte';
|
||||
export { collectionsStore } from './collections.svelte';
|
||||
export { theme } from './theme';
|
||||
116
apps/questions/apps/web/src/lib/stores/questions.svelte.ts
Normal file
116
apps/questions/apps/web/src/lib/stores/questions.svelte.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Questions Store - Manages questions state using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import { questionsApi, type QuestionFilters } from '$lib/api/questions';
|
||||
import type { Question, CreateQuestionDto, UpdateQuestionDto } from '$lib/types';
|
||||
|
||||
let questions = $state<Question[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let total = $state(0);
|
||||
let currentFilters = $state<QuestionFilters>({});
|
||||
|
||||
export const questionsStore = {
|
||||
get questions() {
|
||||
return questions;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get total() {
|
||||
return total;
|
||||
},
|
||||
get filters() {
|
||||
return currentFilters;
|
||||
},
|
||||
|
||||
async load(filters?: QuestionFilters) {
|
||||
loading = true;
|
||||
error = null;
|
||||
currentFilters = filters || {};
|
||||
|
||||
try {
|
||||
const response = await questionsApi.getAll(filters);
|
||||
questions = response.data;
|
||||
total = response.total;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load questions';
|
||||
questions = [];
|
||||
total = 0;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async create(data: CreateQuestionDto): Promise<Question | null> {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const question = await questionsApi.create(data);
|
||||
questions = [question, ...questions];
|
||||
total++;
|
||||
return question;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create question';
|
||||
return null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateQuestionDto): Promise<Question | null> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const updated = await questionsApi.update(id, data);
|
||||
questions = questions.map((q) => (q.id === id ? updated : q));
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update question';
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await questionsApi.delete(id);
|
||||
questions = questions.filter((q) => q.id !== id);
|
||||
total--;
|
||||
return true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete question';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async updateStatus(id: string, status: string): Promise<Question | null> {
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const updated = await questionsApi.updateStatus(id, status);
|
||||
questions = questions.map((q) => (q.id === id ? updated : q));
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update status';
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getById(id: string): Question | undefined {
|
||||
return questions.find((q) => q.id === id);
|
||||
},
|
||||
|
||||
clear() {
|
||||
questions = [];
|
||||
total = 0;
|
||||
error = null;
|
||||
currentFilters = {};
|
||||
},
|
||||
};
|
||||
61
apps/questions/apps/web/src/lib/stores/theme.ts
Normal file
61
apps/questions/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { browser } from '$app/environment';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (!browser) return 'system';
|
||||
|
||||
const stored = localStorage.getItem('theme') as Theme | null;
|
||||
if (stored && ['light', 'dark', 'system'].includes(stored)) {
|
||||
return stored;
|
||||
}
|
||||
return 'system';
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (!browser) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const isDark = theme === 'dark' || (theme === 'system' && systemDark);
|
||||
|
||||
if (isDark) {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
let currentTheme: Theme = 'system';
|
||||
|
||||
export const theme = {
|
||||
get current() {
|
||||
return currentTheme;
|
||||
},
|
||||
|
||||
initialize() {
|
||||
currentTheme = getInitialTheme();
|
||||
applyTheme(currentTheme);
|
||||
|
||||
if (browser) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
if (currentTheme === 'system') {
|
||||
applyTheme('system');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
set(newTheme: Theme) {
|
||||
currentTheme = newTheme;
|
||||
if (browser) {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
}
|
||||
applyTheme(newTheme);
|
||||
},
|
||||
|
||||
toggle() {
|
||||
const next = currentTheme === 'light' ? 'dark' : 'light';
|
||||
this.set(next);
|
||||
},
|
||||
};
|
||||
148
apps/questions/apps/web/src/lib/types/index.ts
Normal file
148
apps/questions/apps/web/src/lib/types/index.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
export interface Collection {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
isDefault: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
questionCount?: number;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: string;
|
||||
userId: string;
|
||||
collectionId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: QuestionStatus;
|
||||
priority: QuestionPriority;
|
||||
tags: string[];
|
||||
researchDepth: ResearchDepth;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type QuestionStatus = 'open' | 'researching' | 'answered' | 'archived';
|
||||
export type QuestionPriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||
export type ResearchDepth = 'quick' | 'standard' | 'deep';
|
||||
|
||||
export interface ResearchResult {
|
||||
id: string;
|
||||
questionId: string;
|
||||
modelId: string;
|
||||
provider: string;
|
||||
researchDepth: ResearchDepth;
|
||||
summary: string;
|
||||
keyPoints: string[];
|
||||
followUpQuestions: string[];
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
estimatedCost?: number;
|
||||
createdAt: string;
|
||||
durationMs?: number;
|
||||
sources?: Source[];
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
id: string;
|
||||
researchResultId: string;
|
||||
url: string;
|
||||
title: string;
|
||||
snippet?: string;
|
||||
domain?: string;
|
||||
extractedContent?: string;
|
||||
contentMarkdown?: string;
|
||||
wordCount?: number;
|
||||
readingTime?: number;
|
||||
relevanceScore?: number;
|
||||
position: number;
|
||||
engine?: string;
|
||||
author?: string;
|
||||
publishedDate?: string;
|
||||
siteName?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Answer {
|
||||
id: string;
|
||||
questionId: string;
|
||||
researchResultId?: string;
|
||||
content: string;
|
||||
contentMarkdown?: string;
|
||||
summary?: string;
|
||||
modelId: string;
|
||||
provider: string;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
estimatedCost?: number;
|
||||
confidence?: number;
|
||||
sourceCount?: number;
|
||||
citations: Citation[];
|
||||
rating?: number;
|
||||
feedback?: string;
|
||||
isAccepted: boolean;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
sourceId: string;
|
||||
text: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface CreateQuestionDto {
|
||||
title: string;
|
||||
description?: string;
|
||||
collectionId?: string;
|
||||
tags?: string[];
|
||||
priority?: QuestionPriority;
|
||||
researchDepth?: ResearchDepth;
|
||||
}
|
||||
|
||||
export interface UpdateQuestionDto {
|
||||
title?: string;
|
||||
description?: string;
|
||||
collectionId?: string;
|
||||
tags?: string[];
|
||||
priority?: QuestionPriority;
|
||||
status?: QuestionStatus;
|
||||
researchDepth?: ResearchDepth;
|
||||
}
|
||||
|
||||
export interface CreateCollectionDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCollectionDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
isDefault?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface StartResearchDto {
|
||||
questionId: string;
|
||||
depth?: ResearchDepth;
|
||||
categories?: string[];
|
||||
engines?: string[];
|
||||
language?: string;
|
||||
maxSources?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
}
|
||||
173
apps/questions/apps/web/src/routes/(app)/+layout.svelte
Normal file
173
apps/questions/apps/web/src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore, collectionsStore, questionsStore } from '$lib/stores';
|
||||
import { apiClient } from '$lib/api/client';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
LogOut,
|
||||
Moon,
|
||||
Sun,
|
||||
HelpCircle,
|
||||
ChevronRight,
|
||||
} from 'lucide-svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
|
||||
let { children } = $props();
|
||||
let sidebarOpen = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (!authStore.isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await authStore.getValidToken();
|
||||
apiClient.setAccessToken(token);
|
||||
|
||||
// Load initial data
|
||||
await collectionsStore.load();
|
||||
await questionsStore.load();
|
||||
});
|
||||
|
||||
async function handleSignOut() {
|
||||
await authStore.signOut();
|
||||
apiClient.setAccessToken(null);
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
function selectCollection(id: string | null) {
|
||||
collectionsStore.select(id);
|
||||
if (id) {
|
||||
questionsStore.load({ collectionId: id });
|
||||
} else {
|
||||
questionsStore.load();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="flex w-64 flex-col border-r border-border bg-card transition-all duration-200"
|
||||
class:w-64={sidebarOpen}
|
||||
class:w-16={!sidebarOpen}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex h-16 items-center justify-between border-b border-border px-4">
|
||||
{#if sidebarOpen}
|
||||
<h1 class="text-xl font-bold text-primary">Questions</h1>
|
||||
{/if}
|
||||
<button
|
||||
onclick={() => (sidebarOpen = !sidebarOpen)}
|
||||
class="rounded-lg p-2 text-muted-foreground hover:bg-secondary"
|
||||
>
|
||||
<ChevronRight class="h-5 w-5 transition-transform" class:rotate-180={sidebarOpen} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New Question Button -->
|
||||
<div class="p-4">
|
||||
<a
|
||||
href="/new"
|
||||
class="flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
{#if sidebarOpen}
|
||||
<span>New Question</span>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 space-y-1 px-2">
|
||||
<button
|
||||
onclick={() => selectCollection(null)}
|
||||
class="collection-item flex w-full items-center gap-3 rounded-lg px-3 py-2 text-foreground"
|
||||
class:active={!collectionsStore.selectedId}
|
||||
>
|
||||
<HelpCircle class="h-5 w-5" />
|
||||
{#if sidebarOpen}
|
||||
<span>All Questions</span>
|
||||
<span class="ml-auto text-xs text-muted-foreground">{questionsStore.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if sidebarOpen}
|
||||
<div class="my-4 px-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||
Collections
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each collectionsStore.collections as collection}
|
||||
<button
|
||||
onclick={() => selectCollection(collection.id)}
|
||||
class="collection-item flex w-full items-center gap-3 rounded-lg px-3 py-2 text-foreground"
|
||||
class:active={collectionsStore.selectedId === collection.id}
|
||||
>
|
||||
<FolderOpen class="h-5 w-5" style="color: {collection.color}" />
|
||||
{#if sidebarOpen}
|
||||
<span class="truncate">{collection.name}</span>
|
||||
<span class="ml-auto text-xs text-muted-foreground">{collection.questionCount || 0}</span
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if sidebarOpen}
|
||||
<a
|
||||
href="/collections"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
<span>Manage Collections</span>
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-border p-2">
|
||||
<button
|
||||
onclick={() => theme.toggle()}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
{#if theme.current === 'dark'}
|
||||
<Sun class="h-5 w-5" />
|
||||
{:else}
|
||||
<Moon class="h-5 w-5" />
|
||||
{/if}
|
||||
{#if sidebarOpen}
|
||||
<span>Toggle Theme</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<Settings class="h-5 w-5" />
|
||||
{#if sidebarOpen}
|
||||
<span>Settings</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<button
|
||||
onclick={handleSignOut}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
<LogOut class="h-5 w-5" />
|
||||
{#if sidebarOpen}
|
||||
<span>Sign Out</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-auto">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
193
apps/questions/apps/web/src/routes/(app)/+page.svelte
Normal file
193
apps/questions/apps/web/src/routes/(app)/+page.svelte
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
<script lang="ts">
|
||||
import { questionsStore, collectionsStore } from '$lib/stores';
|
||||
import { QuestionSkeleton, ErrorAlert } from '$lib/components';
|
||||
import { Search, Filter, Clock, CheckCircle, Loader2, Archive } from 'lucide-svelte';
|
||||
import type { QuestionStatus, ResearchDepth } from '$lib/types';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let statusFilter = $state<QuestionStatus | ''>('');
|
||||
|
||||
const statusIcons = {
|
||||
open: { icon: Clock, color: 'text-gray-500' },
|
||||
researching: { icon: Loader2, color: 'text-blue-500' },
|
||||
answered: { icon: CheckCircle, color: 'text-green-500' },
|
||||
archived: { icon: Archive, color: 'text-gray-400' },
|
||||
};
|
||||
|
||||
const depthLabels: Record<ResearchDepth, string> = {
|
||||
quick: 'Quick',
|
||||
standard: 'Standard',
|
||||
deep: 'Deep',
|
||||
};
|
||||
|
||||
async function handleSearch() {
|
||||
const filters: Record<string, unknown> = {};
|
||||
if (searchQuery) filters.search = searchQuery;
|
||||
if (statusFilter) filters.status = statusFilter;
|
||||
if (collectionsStore.selectedId) filters.collectionId = collectionsStore.selectedId;
|
||||
await questionsStore.load(filters);
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return 'Yesterday';
|
||||
if (days < 7) return `${days} days ago`;
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">
|
||||
{collectionsStore.selected ? collectionsStore.selected.name : 'All Questions'}
|
||||
</h1>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
{questionsStore.total} question{questionsStore.total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="mb-6 flex gap-4">
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
onkeyup={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="Search questions..."
|
||||
class="w-full rounded-lg border border-border bg-background py-2 pl-10 pr-4 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
bind:value={statusFilter}
|
||||
onchange={handleSearch}
|
||||
class="rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="researching">Researching</option>
|
||||
<option value="answered">Answered</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onclick={handleSearch}
|
||||
class="flex items-center gap-2 rounded-lg bg-secondary px-4 py-2 text-foreground hover:bg-secondary-hover"
|
||||
>
|
||||
<Filter class="h-5 w-5" />
|
||||
<span>Filter</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
{#if questionsStore.error}
|
||||
<div class="mb-6">
|
||||
<ErrorAlert
|
||||
message={questionsStore.error}
|
||||
onRetry={() => questionsStore.load(questionsStore.filters)}
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Questions List -->
|
||||
{#if questionsStore.loading}
|
||||
<QuestionSkeleton count={5} />
|
||||
{:else if questionsStore.questions.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<div class="mb-4 text-6xl">🤔</div>
|
||||
<h2 class="mb-2 text-xl font-semibold text-foreground">No questions yet</h2>
|
||||
<p class="mb-4 text-muted-foreground">
|
||||
Start by asking a question and let AI research it for you.
|
||||
</p>
|
||||
<a
|
||||
href="/new"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary-hover"
|
||||
>
|
||||
Ask a Question
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each questionsStore.questions as question}
|
||||
{@const StatusIcon = statusIcons[question.status]?.icon || Clock}
|
||||
{@const statusColor = statusIcons[question.status]?.color || 'text-gray-500'}
|
||||
|
||||
<a
|
||||
href="/question/{question.id}"
|
||||
class="question-card block rounded-xl border border-border bg-card p-4"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Status Icon -->
|
||||
<div class="mt-1">
|
||||
<StatusIcon
|
||||
class="h-5 w-5 {statusColor}"
|
||||
class:animate-spin={question.status === 'researching'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-foreground line-clamp-2">
|
||||
{question.title}
|
||||
</h3>
|
||||
|
||||
{#if question.description}
|
||||
<p class="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{question.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<!-- Tags -->
|
||||
{#if question.tags?.length}
|
||||
<div class="flex gap-1">
|
||||
{#each question.tags.slice(0, 3) as tag}
|
||||
<span
|
||||
class="tag-badge rounded-full bg-secondary px-2 py-0.5 text-xs text-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
{#if question.tags.length > 3}
|
||||
<span class="text-xs text-muted-foreground">+{question.tags.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Depth -->
|
||||
<span class="depth-indicator depth-{question.researchDepth}">
|
||||
{depthLabels[question.researchDepth]}
|
||||
</span>
|
||||
|
||||
<!-- Date -->
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{formatDate(question.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Priority Indicator -->
|
||||
{#if question.priority !== 'normal'}
|
||||
<div
|
||||
class="priority-indicator h-full min-h-[60px] priority-{question.priority}"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
<script lang="ts">
|
||||
import { collectionsStore } from '$lib/stores';
|
||||
import CollectionModal from '$lib/components/CollectionModal.svelte';
|
||||
import { ArrowLeft, Plus, Edit2, Trash2, FolderOpen, GripVertical } from 'lucide-svelte';
|
||||
import type { Collection } from '$lib/types';
|
||||
|
||||
let showModal = $state(false);
|
||||
let editingCollection = $state<Collection | null>(null);
|
||||
let deleteConfirm = $state<string | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
editingCollection = null;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(collection: Collection) {
|
||||
editingCollection = collection;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
editingCollection = null;
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
const success = await collectionsStore.delete(id);
|
||||
if (success) {
|
||||
deleteConfirm = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl p-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<a
|
||||
href="/"
|
||||
class="mb-4 inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to questions
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-foreground">Collections</h1>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
Organize your questions into collections
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={openCreateModal}
|
||||
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary-hover"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
New Collection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Collections List -->
|
||||
{#if collectionsStore.collections.length === 0}
|
||||
<div class="rounded-xl border border-dashed border-border p-8 text-center">
|
||||
<div class="mb-4 text-4xl">📁</div>
|
||||
<h2 class="mb-2 text-lg font-semibold text-foreground">No collections yet</h2>
|
||||
<p class="mb-4 text-muted-foreground">
|
||||
Create your first collection to organize your questions.
|
||||
</p>
|
||||
<button
|
||||
onclick={openCreateModal}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary-hover"
|
||||
>
|
||||
<Plus class="h-5 w-5" />
|
||||
Create Collection
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each collectionsStore.collections as collection}
|
||||
<div
|
||||
class="flex items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50"
|
||||
>
|
||||
<!-- Drag Handle -->
|
||||
<div class="cursor-grab text-muted-foreground">
|
||||
<GripVertical class="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<!-- Icon & Color -->
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg"
|
||||
style="background-color: {collection.color}20"
|
||||
>
|
||||
<FolderOpen class="h-5 w-5" style="color: {collection.color}" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium text-foreground">{collection.name}</h3>
|
||||
{#if collection.isDefault}
|
||||
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
Default
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if collection.description}
|
||||
<p class="mt-0.5 text-sm text-muted-foreground truncate">
|
||||
{collection.description}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{collection.questionCount || 0} questions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => openEditModal(collection)}
|
||||
class="rounded-lg p-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{#if deleteConfirm === collection.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDelete(collection.id)}
|
||||
class="rounded-lg bg-destructive px-3 py-1 text-sm text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (deleteConfirm = null)}
|
||||
class="rounded-lg border border-border px-3 py-1 text-sm text-foreground hover:bg-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (deleteConfirm = collection.id)}
|
||||
class="rounded-lg p-2 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
{#if showModal}
|
||||
<CollectionModal collection={editingCollection} onClose={closeModal} onSave={handleSave} />
|
||||
{/if}
|
||||
212
apps/questions/apps/web/src/routes/(app)/new/+page.svelte
Normal file
212
apps/questions/apps/web/src/routes/(app)/new/+page.svelte
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { questionsStore, collectionsStore } from '$lib/stores';
|
||||
import { researchApi } from '$lib/api/research';
|
||||
import { ArrowLeft, Zap, Clock, Sparkles } from 'lucide-svelte';
|
||||
import type { ResearchDepth, QuestionPriority } from '$lib/types';
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let collectionId = $state<string | undefined>(collectionsStore.selectedId || undefined);
|
||||
let tags = $state<string[]>([]);
|
||||
let tagInput = $state('');
|
||||
let priority = $state<QuestionPriority>('normal');
|
||||
let researchDepth = $state<ResearchDepth>('standard');
|
||||
let startResearch = $state(true);
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const depthOptions: { value: ResearchDepth; label: string; description: string; icon: typeof Zap }[] = [
|
||||
{ value: 'quick', label: 'Quick', description: '5 sources, fast results', icon: Zap },
|
||||
{ value: 'standard', label: 'Standard', description: '15 sources, balanced', icon: Clock },
|
||||
{ value: 'deep', label: 'Deep', description: '30+ sources, comprehensive', icon: Sparkles },
|
||||
];
|
||||
|
||||
function addTag() {
|
||||
const tag = tagInput.trim().toLowerCase();
|
||||
if (tag && !tags.includes(tag)) {
|
||||
tags = [...tags, tag];
|
||||
}
|
||||
tagInput = '';
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
tags = tags.filter((t) => t !== tag);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim()) {
|
||||
error = 'Please enter a question';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const question = await questionsStore.create({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
collectionId,
|
||||
tags,
|
||||
priority,
|
||||
researchDepth,
|
||||
});
|
||||
|
||||
if (question) {
|
||||
if (startResearch) {
|
||||
// Start research in the background
|
||||
researchApi.start({ questionId: question.id, depth: researchDepth }).catch(console.error);
|
||||
}
|
||||
goto(`/question/${question.id}`);
|
||||
} else {
|
||||
error = questionsStore.error || 'Failed to create question';
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl p-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/"
|
||||
class="mb-4 inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to questions
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-foreground">Ask a Question</h1>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
Enter your question and let AI research it for you
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
{#if error}
|
||||
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Question Title -->
|
||||
<div>
|
||||
<label for="title" class="mb-2 block font-medium text-foreground">Your Question</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
bind:value={title}
|
||||
placeholder="What would you like to know?"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-3 text-lg text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="mb-2 block font-medium text-foreground">
|
||||
Additional Context <span class="text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Provide any additional details or context..."
|
||||
rows="3"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Collection -->
|
||||
<div>
|
||||
<label for="collection" class="mb-2 block font-medium text-foreground">Collection</label>
|
||||
<select
|
||||
id="collection"
|
||||
bind:value={collectionId}
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
>
|
||||
<option value={undefined}>No collection</option>
|
||||
{#each collectionsStore.collections as collection}
|
||||
<option value={collection.id}>{collection.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label for="tags" class="mb-2 block font-medium text-foreground">Tags</label>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
{#each tags as tag}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-secondary px-3 py-1 text-sm">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(tag)}
|
||||
class="ml-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
bind:value={tagInput}
|
||||
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||
placeholder="Add a tag and press Enter"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Research Depth -->
|
||||
<div>
|
||||
<label class="mb-2 block font-medium text-foreground">Research Depth</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{#each depthOptions as option}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (researchDepth = option.value)}
|
||||
class="rounded-lg border-2 p-4 text-left transition-all {researchDepth === option.value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'}"
|
||||
>
|
||||
<svelte:component this={option.icon} class="mb-2 h-5 w-5 text-primary" />
|
||||
<div class="font-medium text-foreground">{option.label}</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">{option.description}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start Research Toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="startResearch"
|
||||
bind:checked={startResearch}
|
||||
class="h-5 w-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
<label for="startResearch" class="text-foreground">
|
||||
Start research immediately after creating
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="flex gap-3">
|
||||
<a
|
||||
href="/"
|
||||
class="flex-1 rounded-lg border border-border px-4 py-3 text-center font-medium text-foreground hover:bg-secondary"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !title.trim()}
|
||||
class="flex-1 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground hover:bg-primary-hover disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Ask Question'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,315 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { questionsApi } from '$lib/api/questions';
|
||||
import { researchApi } from '$lib/api/research';
|
||||
import { sourcesApi } from '$lib/api/sources';
|
||||
import { QuestionDetailSkeleton, ErrorAlert } from '$lib/components';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
Archive,
|
||||
Play,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-svelte';
|
||||
import type { Question, ResearchResult, Source } from '$lib/types';
|
||||
|
||||
let question = $state<Question | null>(null);
|
||||
let researchResults = $state<ResearchResult[]>([]);
|
||||
let sources = $state<Source[]>([]);
|
||||
let loading = $state(true);
|
||||
let researchLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let expandedSources = $state<Set<string>>(new Set());
|
||||
|
||||
const statusLabels = {
|
||||
open: { label: 'Open', color: 'bg-gray-100 text-gray-700' },
|
||||
researching: { label: 'Researching', color: 'bg-blue-100 text-blue-700' },
|
||||
answered: { label: 'Answered', color: 'bg-green-100 text-green-700' },
|
||||
archived: { label: 'Archived', color: 'bg-gray-100 text-gray-500' },
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await loadQuestion();
|
||||
});
|
||||
|
||||
async function loadQuestion() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const id = page.params.id;
|
||||
question = await questionsApi.getById(id);
|
||||
researchResults = await researchApi.getByQuestion(id);
|
||||
sources = await sourcesApi.getByQuestion(id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load question';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startResearch() {
|
||||
if (!question) return;
|
||||
|
||||
researchLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await researchApi.start({
|
||||
questionId: question.id,
|
||||
depth: question.researchDepth,
|
||||
});
|
||||
researchResults = [result, ...researchResults];
|
||||
sources = await sourcesApi.getByQuestion(question.id);
|
||||
// Reload question to get updated status
|
||||
question = await questionsApi.getById(question.id);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to start research';
|
||||
} finally {
|
||||
researchLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSource(id: string) {
|
||||
if (expandedSources.has(id)) {
|
||||
expandedSources.delete(id);
|
||||
expandedSources = new Set(expandedSources);
|
||||
} else {
|
||||
expandedSources.add(id);
|
||||
expandedSources = new Set(expandedSources);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<QuestionDetailSkeleton />
|
||||
{:else if error}
|
||||
<div class="p-6">
|
||||
<ErrorAlert message={error} onRetry={loadQuestion} />
|
||||
</div>
|
||||
{:else if question}
|
||||
<div class="p-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<a
|
||||
href="/"
|
||||
class="mb-4 inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to questions
|
||||
</a>
|
||||
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-foreground">{question.title}</h1>
|
||||
{#if question.description}
|
||||
<p class="mt-2 text-muted-foreground">{question.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<!-- Status Badge -->
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-sm font-medium {statusLabels[question.status]
|
||||
.color}"
|
||||
>
|
||||
{statusLabels[question.status].label}
|
||||
</span>
|
||||
|
||||
<!-- Depth -->
|
||||
<span class="depth-indicator depth-{question.researchDepth}">
|
||||
{question.researchDepth}
|
||||
</span>
|
||||
|
||||
<!-- Tags -->
|
||||
{#if question.tags?.length}
|
||||
{#each question.tags as tag}
|
||||
<span class="rounded-full bg-secondary px-2 py-0.5 text-xs text-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Date -->
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{formatDate(question.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
{#if question.status === 'open'}
|
||||
<button
|
||||
onclick={startResearch}
|
||||
disabled={researchLoading}
|
||||
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground hover:bg-primary-hover disabled:opacity-50"
|
||||
>
|
||||
{#if researchLoading}
|
||||
<Loader2 class="h-5 w-5 animate-spin" />
|
||||
Researching...
|
||||
{:else}
|
||||
<Play class="h-5 w-5" />
|
||||
Start Research
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Research Results -->
|
||||
{#if researchResults.length > 0}
|
||||
<div class="mb-8">
|
||||
<h2 class="mb-4 text-lg font-semibold text-foreground">Research Results</h2>
|
||||
|
||||
{#each researchResults as result}
|
||||
<div class="mb-6 rounded-xl border border-border bg-card p-6">
|
||||
<!-- Summary -->
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2 font-medium text-foreground">Summary</h3>
|
||||
<div class="markdown-content text-foreground whitespace-pre-wrap">
|
||||
{result.summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Points -->
|
||||
{#if result.keyPoints?.length}
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2 font-medium text-foreground">Key Points</h3>
|
||||
<ul class="list-disc space-y-1 pl-5 text-foreground">
|
||||
{#each result.keyPoints as point}
|
||||
<li>{point}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Follow-up Questions -->
|
||||
{#if result.followUpQuestions?.length}
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2 font-medium text-foreground">Follow-up Questions</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each result.followUpQuestions as followUp}
|
||||
<li class="text-muted-foreground">{followUp}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Meta -->
|
||||
<div class="mt-4 flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>Depth: {result.researchDepth}</span>
|
||||
{#if result.durationMs}
|
||||
<span>Duration: {(result.durationMs / 1000).toFixed(1)}s</span>
|
||||
{/if}
|
||||
<span>{formatDate(result.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if question.status === 'open'}
|
||||
<div class="mb-8 rounded-xl border border-dashed border-border p-8 text-center">
|
||||
<div class="mb-4 text-4xl">🔍</div>
|
||||
<h2 class="mb-2 text-lg font-semibold text-foreground">No research yet</h2>
|
||||
<p class="mb-4 text-muted-foreground">
|
||||
Click "Start Research" to begin gathering information about this question.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sources -->
|
||||
{#if sources.length > 0}
|
||||
<div>
|
||||
<h2 class="mb-4 text-lg font-semibold text-foreground">Sources ({sources.length})</h2>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each sources as source}
|
||||
<div class="source-card rounded-lg border border-border bg-card p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted-foreground">#{source.position}</span>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-foreground hover:text-primary"
|
||||
>
|
||||
{source.title}
|
||||
</a>
|
||||
<ExternalLink class="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<p class="mt-1 text-sm text-muted-foreground">{source.domain}</p>
|
||||
|
||||
{#if source.snippet}
|
||||
<p class="mt-2 text-sm text-foreground line-clamp-2">
|
||||
{source.snippet}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if source.extractedContent && expandedSources.has(source.id)}
|
||||
<div class="mt-4 rounded-lg bg-secondary/50 p-4">
|
||||
<div class="markdown-content text-sm text-foreground whitespace-pre-wrap">
|
||||
{source.extractedContent.substring(0, 2000)}
|
||||
{#if source.extractedContent.length > 2000}
|
||||
<span class="text-muted-foreground">... (truncated)</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3 flex items-center gap-4">
|
||||
{#if source.relevanceScore}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Score: {(source.relevanceScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
{/if}
|
||||
{#if source.wordCount}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{source.wordCount} words
|
||||
</span>
|
||||
{/if}
|
||||
{#if source.engine}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
via {source.engine}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if source.extractedContent}
|
||||
<button
|
||||
onclick={() => toggleSource(source.id)}
|
||||
class="rounded-lg p-2 text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
{#if expandedSources.has(source.id)}
|
||||
<ChevronUp class="h-5 w-5" />
|
||||
{:else}
|
||||
<ChevronDown class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
178
apps/questions/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
178
apps/questions/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { ArrowLeft, User, Moon, Sun, Monitor, Bell, Shield, Trash2 } from 'lucide-svelte';
|
||||
|
||||
let currentTheme = $state(theme.current);
|
||||
let deleteConfirm = $state(false);
|
||||
|
||||
function setTheme(newTheme: 'light' | 'dark' | 'system') {
|
||||
theme.set(newTheme);
|
||||
currentTheme = newTheme;
|
||||
}
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light', label: 'Light', icon: Sun },
|
||||
{ value: 'dark', label: 'Dark', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl p-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<a
|
||||
href="/"
|
||||
class="mb-4 inline-flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to questions
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold text-foreground">Settings</h1>
|
||||
</div>
|
||||
|
||||
<!-- Account Section -->
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<User class="h-5 w-5" />
|
||||
Account
|
||||
</h2>
|
||||
<div class="rounded-xl border border-border bg-card p-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">Email</label>
|
||||
<p class="font-medium text-foreground">{authStore.user?.email || 'Not signed in'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-muted-foreground">User ID</label>
|
||||
<p class="font-mono text-sm text-foreground">{authStore.user?.id || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<Moon class="h-5 w-5" />
|
||||
Appearance
|
||||
</h2>
|
||||
<div class="rounded-xl border border-border bg-card p-6">
|
||||
<label class="mb-3 block text-sm font-medium text-foreground">Theme</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{#each themeOptions as option}
|
||||
<button
|
||||
onclick={() => setTheme(option.value)}
|
||||
class="flex flex-col items-center gap-2 rounded-lg border-2 p-4 transition-all {currentTheme ===
|
||||
option.value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'}"
|
||||
>
|
||||
<svelte:component this={option.icon} class="h-6 w-6" />
|
||||
<span class="text-sm font-medium">{option.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Notifications Section -->
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<Bell class="h-5 w-5" />
|
||||
Notifications
|
||||
</h2>
|
||||
<div class="rounded-xl border border-border bg-card p-6">
|
||||
<div class="space-y-4">
|
||||
<label class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Research Complete</p>
|
||||
<p class="text-sm text-muted-foreground">Get notified when research is finished</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
class="h-5 w-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Weekly Summary</p>
|
||||
<p class="text-sm text-muted-foreground">Receive a weekly summary of your questions</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 rounded border-border text-primary focus:ring-primary"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Privacy Section -->
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<Shield class="h-5 w-5" />
|
||||
Privacy & Data
|
||||
</h2>
|
||||
<div class="rounded-xl border border-border bg-card p-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">Export Data</p>
|
||||
<p class="mb-2 text-sm text-muted-foreground">
|
||||
Download all your questions and research data
|
||||
</p>
|
||||
<button
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-secondary"
|
||||
>
|
||||
Export as JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="border-border" />
|
||||
|
||||
<div>
|
||||
<p class="font-medium text-destructive">Delete Account</p>
|
||||
<p class="mb-2 text-sm text-muted-foreground">
|
||||
Permanently delete your account and all data
|
||||
</p>
|
||||
{#if deleteConfirm}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (deleteConfirm = false)}
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (deleteConfirm = true)}
|
||||
class="flex items-center gap-2 rounded-lg border border-destructive px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Delete Account
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About Section -->
|
||||
<section class="mb-8">
|
||||
<div class="rounded-xl border border-border bg-card p-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Questions App v1.0.0
|
||||
<br />
|
||||
Powered by mana-search
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
18
apps/questions/apps/web/src/routes/(auth)/+layout.svelte
Normal file
18
apps/questions/apps/web/src/routes/(auth)/+layout.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (authStore.initialized && authStore.isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-gradient-to-br from-primary/5 to-accent/5">
|
||||
<div class="w-full max-w-md px-4">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
let email = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let success = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
const result = await authStore.resetPassword(email);
|
||||
|
||||
if (result.success) {
|
||||
success = true;
|
||||
} else {
|
||||
error = result.error || 'Failed to send reset email';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl bg-card p-8 shadow-lg">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-2xl font-bold text-foreground">Reset Password</h1>
|
||||
<p class="mt-2 text-muted-foreground">Enter your email to receive a reset link</p>
|
||||
</div>
|
||||
|
||||
{#if success}
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-4xl">📧</div>
|
||||
<h2 class="mb-2 text-lg font-semibold text-foreground">Check your email</h2>
|
||||
<p class="mb-4 text-muted-foreground">
|
||||
We've sent a password reset link to <strong>{email}</strong>. Please check your inbox.
|
||||
</p>
|
||||
<a href="/login" class="text-primary hover:underline">Back to login</a>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
{#if error}
|
||||
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to login
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
81
apps/questions/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
81
apps/questions/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiClient } from '$lib/api/client';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
const result = await authStore.signIn(email, password);
|
||||
|
||||
if (result.success) {
|
||||
const token = await authStore.getValidToken();
|
||||
apiClient.setAccessToken(token);
|
||||
goto('/');
|
||||
} else {
|
||||
error = result.error || 'Login failed';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl bg-card p-8 shadow-lg">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-2xl font-bold text-foreground">Questions</h1>
|
||||
<p class="mt-2 text-muted-foreground">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
{#if error}
|
||||
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-1 block text-sm font-medium text-foreground">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center text-sm text-muted-foreground">
|
||||
<a href="/forgot-password" class="text-primary hover:underline">Forgot password?</a>
|
||||
<span class="mx-2">·</span>
|
||||
<a href="/register" class="text-primary hover:underline">Create account</a>
|
||||
</div>
|
||||
</div>
|
||||
125
apps/questions/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
125
apps/questions/apps/web/src/routes/(auth)/register/+page.svelte
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiClient } from '$lib/api/client';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let needsVerification = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
error = 'Password must be at least 8 characters';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
const result = await authStore.signUp(email, password);
|
||||
|
||||
if (result.success) {
|
||||
if (result.needsVerification) {
|
||||
needsVerification = true;
|
||||
} else {
|
||||
const token = await authStore.getValidToken();
|
||||
apiClient.setAccessToken(token);
|
||||
goto('/');
|
||||
}
|
||||
} else {
|
||||
error = result.error || 'Registration failed';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl bg-card p-8 shadow-lg">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-2xl font-bold text-foreground">Questions</h1>
|
||||
<p class="mt-2 text-muted-foreground">Create your account</p>
|
||||
</div>
|
||||
|
||||
{#if needsVerification}
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-4xl">📧</div>
|
||||
<h2 class="mb-2 text-lg font-semibold">Check your email</h2>
|
||||
<p class="text-muted-foreground">
|
||||
We've sent a verification link to <strong>{email}</strong>. Please check your inbox and
|
||||
click the link to verify your account.
|
||||
</p>
|
||||
<a href="/login" class="mt-4 inline-block text-primary hover:underline">Back to login</a>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
{#if error}
|
||||
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="mb-1 block text-sm font-medium text-foreground">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
required
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="mb-1 block text-sm font-medium text-foreground">Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirmPassword" class="mb-1 block text-sm font-medium text-foreground"
|
||||
>Confirm Password</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground transition-colors hover:bg-primary-hover disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center text-sm text-muted-foreground">
|
||||
Already have an account?
|
||||
<a href="/login" class="text-primary hover:underline">Sign in</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
33
apps/questions/apps/web/src/routes/+layout.svelte
Normal file
33
apps/questions/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { apiClient } from '$lib/api/client';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
theme.initialize();
|
||||
await authStore.initialize();
|
||||
|
||||
// Set token in API client when authenticated
|
||||
if (authStore.isAuthenticated) {
|
||||
const token = await authStore.getValidToken();
|
||||
apiClient.setAccessToken(token);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
8
apps/questions/apps/web/src/routes/health/+server.ts
Normal file
8
apps/questions/apps/web/src/routes/health/+server.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export async function GET() {
|
||||
return json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
14
apps/questions/apps/web/svelte.config.js
Normal file
14
apps/questions/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/questions/apps/web/tsconfig.json
Normal file
14
apps/questions/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
35
apps/questions/apps/web/vite.config.ts
Normal file
35
apps/questions/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
server: {
|
||||
port: 5111,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [
|
||||
'@manacore/shared-icons',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-tailwind',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-auth',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [
|
||||
'@manacore/shared-icons',
|
||||
'@manacore/shared-ui',
|
||||
'@manacore/shared-tailwind',
|
||||
'@manacore/shared-theme',
|
||||
'@manacore/shared-theme-ui',
|
||||
'@manacore/shared-auth',
|
||||
'@manacore/shared-auth-ui',
|
||||
'@manacore/shared-branding',
|
||||
],
|
||||
},
|
||||
});
|
||||
9
apps/questions/package.json
Normal file
9
apps/questions/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "questions",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Questions app - Collect questions and research answers",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev"
|
||||
}
|
||||
}
|
||||
15
package.json
15
package.json
|
|
@ -212,6 +212,21 @@
|
|||
"cf:login": "npx wrangler login",
|
||||
"cf:projects:list": "npx wrangler pages project list",
|
||||
"cf:projects:create": "echo 'Creating Cloudflare Pages projects...' && npx wrangler pages project create chat-landing --production-branch=main && npx wrangler pages project create picture-landing --production-branch=main && npx wrangler pages project create manacore-landing --production-branch=main && npx wrangler pages project create manadeck-landing --production-branch=main && npx wrangler pages project create zitare-landing --production-branch=main",
|
||||
"dev:search": "pnpm --filter @manacore/mana-search dev",
|
||||
"dev:search:docker": "docker-compose -f services/mana-search/docker-compose.dev.yml up -d",
|
||||
"dev:search:docker:down": "docker-compose -f services/mana-search/docker-compose.dev.yml down",
|
||||
"dev:search:docker:logs": "docker-compose -f services/mana-search/docker-compose.dev.yml logs -f",
|
||||
"dev:search:full": "docker-compose -f services/mana-search/docker-compose.dev.yml up -d && pnpm --filter @manacore/mana-search dev",
|
||||
"search:docker:up": "docker-compose -f services/mana-search/docker-compose.yml up -d",
|
||||
"search:docker:down": "docker-compose -f services/mana-search/docker-compose.yml down",
|
||||
"search:docker:logs": "docker-compose -f services/mana-search/docker-compose.yml logs -f",
|
||||
"questions:dev": "turbo run dev --filter=questions...",
|
||||
"dev:questions:backend": "pnpm --filter @questions/backend dev",
|
||||
"dev:questions:web": "pnpm --filter @questions/web dev",
|
||||
"dev:questions:app": "turbo run dev --filter=@questions/web --filter=@questions/backend",
|
||||
"dev:questions:full": "./scripts/setup-databases.sh questions && ./scripts/setup-databases.sh auth && pnpm dev:search:docker && concurrently -n auth,search,backend,web -c blue,yellow,green,cyan \"pnpm dev:auth\" \"pnpm dev:search\" \"pnpm dev:questions:backend\" \"pnpm dev:questions:web\"",
|
||||
"questions:db:push": "pnpm --filter @questions/backend db:push",
|
||||
"questions:db:studio": "pnpm --filter @questions/backend db:studio",
|
||||
"dev:projectdoc": "pnpm --filter @manacore/telegram-project-doc-bot start:dev",
|
||||
"dev:projectdoc:full": "./scripts/setup-databases.sh projectdoc && pnpm dev:projectdoc",
|
||||
"projectdoc:db:push": "pnpm --filter @manacore/telegram-project-doc-bot db:push",
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ ALL_DATABASES=(
|
|||
"zitare_bot"
|
||||
"todo_bot"
|
||||
"nutriphi_bot"
|
||||
"questions"
|
||||
)
|
||||
|
||||
# Check if specific service requested
|
||||
|
|
@ -175,9 +176,13 @@ setup_service() {
|
|||
create_db_if_not_exists "nutriphi_bot"
|
||||
push_schema "@manacore/telegram-nutriphi-bot" "nutriphi-bot"
|
||||
;;
|
||||
questions)
|
||||
create_db_if_not_exists "questions"
|
||||
push_schema "@questions/backend" "questions"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unknown service: $service${NC}"
|
||||
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot"
|
||||
echo "Available services: auth, chat, zitare, contacts, calendar, clock, todo, manadeck, mail, moodlit, finance, voxel-lava, figgos, planta, nutriphi, presi, storage, projectdoc, zitare_bot, todo_bot, nutriphi_bot, questions"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -201,7 +206,7 @@ echo -e "\n${GREEN}Step 2: Pushing schemas${NC}"
|
|||
echo "--------------------------------------"
|
||||
|
||||
# Push schemas for all known services
|
||||
for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos planta nutriphi presi storage; do
|
||||
for service in auth chat zitare contacts calendar clock todo manadeck picture mail moodlit finance voxel-lava figgos planta nutriphi presi storage questions; do
|
||||
setup_service "$service" 2>/dev/null || true
|
||||
done
|
||||
|
||||
|
|
|
|||
28
services/mana-search/.env.example
Normal file
28
services/mana-search/.env.example
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# Mana Search Service Environment Variables
|
||||
|
||||
# Server
|
||||
PORT=3021
|
||||
NODE_ENV=development
|
||||
|
||||
# SearXNG
|
||||
SEARXNG_URL=http://localhost:8080
|
||||
SEARXNG_TIMEOUT=15000
|
||||
SEARXNG_DEFAULT_LANGUAGE=de-DE
|
||||
SEARXNG_SECRET=change-me-in-production
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6380
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Cache TTL (seconds)
|
||||
CACHE_SEARCH_TTL=3600
|
||||
CACHE_EXTRACT_TTL=86400
|
||||
|
||||
# Content Extraction
|
||||
EXTRACT_TIMEOUT=10000
|
||||
EXTRACT_MAX_LENGTH=50000
|
||||
EXTRACT_USER_AGENT=Mozilla/5.0 (compatible; ManaSearchBot/1.0; +https://manacore.app)
|
||||
|
||||
# CORS (comma-separated origins)
|
||||
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://localhost:8081
|
||||
250
services/mana-search/CLAUDE.md
Normal file
250
services/mana-search/CLAUDE.md
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
# Mana Search Service
|
||||
|
||||
Central search microservice providing web search and content extraction for all ManaCore apps.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Port**: 3021
|
||||
- **Technology**: NestJS + SearXNG + Redis
|
||||
- **Purpose**: Unified search and extraction API
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Consumer Apps │
|
||||
│ Questions │ Chat │ Project Doc Bot │ Future Apps │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ mana-search (Port 3021) │
|
||||
│ Search API │ Extract API │ Redis Cache │
|
||||
└─────────────────────────┬───────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ SearXNG (Port 8080, internal) │
|
||||
│ Google │ Bing │ DuckDuckGo │ Wikipedia │ arXiv │ ... │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Development (Local NestJS + Docker SearXNG/Redis)
|
||||
|
||||
```bash
|
||||
# 1. Start SearXNG and Redis
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# 2. Install dependencies
|
||||
pnpm install
|
||||
|
||||
# 3. Start NestJS in watch mode
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Production (Full Docker)
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Search
|
||||
|
||||
```bash
|
||||
# Web search
|
||||
POST /api/v1/search
|
||||
{
|
||||
"query": "quantum computing",
|
||||
"options": {
|
||||
"categories": ["general", "science"],
|
||||
"engines": ["google", "wikipedia"],
|
||||
"language": "de-DE",
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
|
||||
# Get available engines
|
||||
GET /api/v1/search/engines
|
||||
|
||||
# Search health check
|
||||
GET /api/v1/search/health
|
||||
|
||||
# Clear search cache
|
||||
DELETE /api/v1/search/cache
|
||||
```
|
||||
|
||||
### Extract
|
||||
|
||||
```bash
|
||||
# Extract content from URL
|
||||
POST /api/v1/extract
|
||||
{
|
||||
"url": "https://example.com/article",
|
||||
"options": {
|
||||
"includeMarkdown": true,
|
||||
"maxLength": 5000
|
||||
}
|
||||
}
|
||||
|
||||
# Bulk extract (max 20 URLs)
|
||||
POST /api/v1/extract/bulk
|
||||
{
|
||||
"urls": ["https://...", "https://..."],
|
||||
"options": { "includeMarkdown": true },
|
||||
"concurrency": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Health & Metrics
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
GET /health
|
||||
|
||||
# Prometheus metrics
|
||||
GET /metrics
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | 3021 | API port |
|
||||
| `SEARXNG_URL` | http://localhost:8080 | SearXNG URL |
|
||||
| `SEARXNG_TIMEOUT` | 15000 | Search timeout (ms) |
|
||||
| `SEARXNG_DEFAULT_LANGUAGE` | de-DE | Default language |
|
||||
| `REDIS_HOST` | localhost | Redis host |
|
||||
| `REDIS_PORT` | 6379 | Redis port |
|
||||
| `CACHE_SEARCH_TTL` | 3600 | Search cache TTL (seconds) |
|
||||
| `CACHE_EXTRACT_TTL` | 86400 | Extract cache TTL (seconds) |
|
||||
| `EXTRACT_TIMEOUT` | 10000 | Extraction timeout (ms) |
|
||||
| `EXTRACT_MAX_LENGTH` | 50000 | Max extracted text length |
|
||||
|
||||
### SearXNG Configuration
|
||||
|
||||
Edit `searxng/settings.yml` to:
|
||||
- Enable/disable search engines
|
||||
- Configure rate limits
|
||||
- Set default language
|
||||
- Adjust timeouts
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start development server
|
||||
pnpm dev
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
|
||||
# Start production server
|
||||
pnpm start
|
||||
|
||||
# Type checking
|
||||
pnpm type-check
|
||||
|
||||
# Linting
|
||||
pnpm lint
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Docker Commands
|
||||
|
||||
```bash
|
||||
# Start all services (production)
|
||||
docker-compose up -d
|
||||
|
||||
# Start SearXNG + Redis only (development)
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop services
|
||||
docker-compose down
|
||||
|
||||
# Rebuild
|
||||
docker-compose build --no-cache
|
||||
```
|
||||
|
||||
## Testing the API
|
||||
|
||||
```bash
|
||||
# Search test
|
||||
curl -X POST http://localhost:3021/api/v1/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "typescript tutorial"}'
|
||||
|
||||
# Extract test
|
||||
curl -X POST http://localhost:3021/api/v1/extract \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://en.wikipedia.org/wiki/TypeScript", "options": {"includeMarkdown": true}}'
|
||||
|
||||
# Health check
|
||||
curl http://localhost:3021/health
|
||||
```
|
||||
|
||||
## Search Categories
|
||||
|
||||
| Category | Engines |
|
||||
|----------|---------|
|
||||
| `general` | Google, Bing, DuckDuckGo, Brave, Wikipedia |
|
||||
| `news` | Google News, Bing News |
|
||||
| `science` | arXiv, Google Scholar, PubMed, Semantic Scholar |
|
||||
| `it` | GitHub, StackOverflow, NPM, MDN |
|
||||
| `images` | Google Images, Bing Images, Unsplash |
|
||||
| `videos` | YouTube, Vimeo, PeerTube |
|
||||
|
||||
## Integration Example
|
||||
|
||||
```typescript
|
||||
// In another service
|
||||
const response = await fetch('http://mana-search:3021/api/v1/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: 'machine learning basics',
|
||||
options: {
|
||||
categories: ['general', 'science'],
|
||||
limit: 5
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const { results, meta } = await response.json();
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### SearXNG not responding
|
||||
|
||||
```bash
|
||||
# Check SearXNG health
|
||||
curl http://localhost:8080/healthz
|
||||
|
||||
# Check logs
|
||||
docker logs mana-searxng-dev
|
||||
```
|
||||
|
||||
### Redis connection issues
|
||||
|
||||
```bash
|
||||
# Check Redis
|
||||
docker exec mana-search-redis-dev redis-cli ping
|
||||
|
||||
# Clear Redis data
|
||||
docker exec mana-search-redis-dev redis-cli FLUSHALL
|
||||
```
|
||||
|
||||
### High memory usage
|
||||
|
||||
SearXNG can use significant memory. Adjust `maxmemory` in docker-compose if needed.
|
||||
60
services/mana-search/Dockerfile
Normal file
60
services/mana-search/Dockerfile
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# ================================
|
||||
# Build Stage
|
||||
# ================================
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@9.15.0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY tsconfig.json nest-cli.json ./
|
||||
COPY src ./src
|
||||
|
||||
# Build the application
|
||||
RUN pnpm build
|
||||
|
||||
# ================================
|
||||
# Production Stage
|
||||
# ================================
|
||||
FROM node:20-slim AS production
|
||||
|
||||
# Install pnpm
|
||||
RUN npm install -g pnpm@9.15.0
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -r nestjs && useradd -r -g nestjs nestjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# Install production dependencies only
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R nestjs:nestjs /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER nestjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3021
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:3021/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/main"]
|
||||
58
services/mana-search/docker-compose.dev.yml
Normal file
58
services/mana-search/docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
version: '3.8'
|
||||
|
||||
# Development setup - SearXNG and Redis only
|
||||
# Run mana-search with `pnpm dev` locally
|
||||
|
||||
services:
|
||||
# ================================
|
||||
# SearXNG Meta Search Engine
|
||||
# ================================
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: mana-searxng-dev
|
||||
ports:
|
||||
- "8080:8080" # Exposed for development
|
||||
volumes:
|
||||
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
|
||||
- ./searxng/limiter.toml:/etc/searxng/limiter.toml:ro
|
||||
environment:
|
||||
SEARXNG_BASE_URL: http://localhost:8080
|
||||
SEARXNG_SECRET: dev-secret-change-in-production
|
||||
networks:
|
||||
- mana-search-dev
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# ================================
|
||||
# Redis Cache
|
||||
# ================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: mana-search-redis-dev
|
||||
ports:
|
||||
- "6380:6379" # Different port to avoid conflicts
|
||||
command: redis-server --appendonly yes --maxmemory 64mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis-dev-data:/data
|
||||
networks:
|
||||
- mana-search-dev
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
redis-dev-data:
|
||||
name: mana-search-redis-dev-data
|
||||
|
||||
networks:
|
||||
mana-search-dev:
|
||||
name: mana-search-dev
|
||||
driver: bridge
|
||||
96
services/mana-search/docker-compose.yml
Normal file
96
services/mana-search/docker-compose.yml
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ================================
|
||||
# NestJS API Service
|
||||
# ================================
|
||||
mana-search:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: mana-search
|
||||
ports:
|
||||
- "3021:3021"
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-development}
|
||||
PORT: 3021
|
||||
SEARXNG_URL: http://searxng:8080
|
||||
SEARXNG_TIMEOUT: 15000
|
||||
SEARXNG_DEFAULT_LANGUAGE: de-DE
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
CACHE_SEARCH_TTL: 3600
|
||||
CACHE_EXTRACT_TTL: 86400
|
||||
EXTRACT_TIMEOUT: 10000
|
||||
EXTRACT_MAX_LENGTH: 50000
|
||||
depends_on:
|
||||
searxng:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mana-search-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3021/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# ================================
|
||||
# SearXNG Meta Search Engine
|
||||
# ================================
|
||||
searxng:
|
||||
image: searxng/searxng:latest
|
||||
container_name: mana-searxng
|
||||
volumes:
|
||||
- ./searxng/settings.yml:/etc/searxng/settings.yml:ro
|
||||
- ./searxng/limiter.toml:/etc/searxng/limiter.toml:ro
|
||||
environment:
|
||||
SEARXNG_BASE_URL: http://localhost:8080
|
||||
SEARXNG_SECRET: ${SEARXNG_SECRET:-change-me-in-production-please}
|
||||
networks:
|
||||
- mana-search-network
|
||||
# Internal only - no external port mapping in production
|
||||
# Uncomment for debugging:
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
# ================================
|
||||
# Redis Cache
|
||||
# ================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: mana-search-redis
|
||||
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
networks:
|
||||
- mana-search-network
|
||||
# Internal only - no external port mapping
|
||||
# Uncomment for debugging:
|
||||
# ports:
|
||||
# - "6380:6379"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
name: mana-search-redis-data
|
||||
|
||||
networks:
|
||||
mana-search-network:
|
||||
name: mana-search-network
|
||||
driver: bridge
|
||||
8
services/mana-search/nest-cli.json
Normal file
8
services/mana-search/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
47
services/mana-search/package.json
Normal file
47
services/mana-search/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@manacore/mana-search",
|
||||
"version": "1.0.0",
|
||||
"description": "Central search microservice with SearXNG and content extraction",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@extractus/article-extractor": "^8.0.18",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"ioredis": "^5.4.2",
|
||||
"prom-client": "^15.1.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"turndown": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
27
services/mana-search/searxng/limiter.toml
Normal file
27
services/mana-search/searxng/limiter.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# SearXNG Rate Limiter Configuration
|
||||
# Documentation: https://docs.searxng.org/admin/settings/limiter.html
|
||||
|
||||
[botdetection.ip_limit]
|
||||
# Enable link token for bot detection
|
||||
link_token = true
|
||||
|
||||
# Maximum searches per minute per IP
|
||||
limit = 60
|
||||
|
||||
# Burst limit (requests before rate limiting kicks in)
|
||||
burst = 20
|
||||
|
||||
[botdetection.ip_lists]
|
||||
# Allow internal Docker network IPs (no rate limiting for internal services)
|
||||
pass_ip = [
|
||||
# Docker internal networks
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"10.0.0.0/8",
|
||||
# Localhost
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
]
|
||||
|
||||
# Block known bad actors (add IPs as needed)
|
||||
block_ip = []
|
||||
242
services/mana-search/searxng/settings.yml
Normal file
242
services/mana-search/searxng/settings.yml
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
use_default_settings: true
|
||||
|
||||
general:
|
||||
instance_name: "ManaCore Search"
|
||||
debug: false
|
||||
privacypolicy_url: false
|
||||
donation_url: false
|
||||
contact_url: false
|
||||
enable_metrics: true
|
||||
|
||||
search:
|
||||
safe_search: 0
|
||||
autocomplete: "google"
|
||||
default_lang: "de-DE"
|
||||
formats:
|
||||
- html
|
||||
- json
|
||||
|
||||
server:
|
||||
secret_key: "${SEARXNG_SECRET}"
|
||||
limiter: true
|
||||
image_proxy: false
|
||||
method: "GET"
|
||||
bind_address: "0.0.0.0"
|
||||
port: 8080
|
||||
|
||||
ui:
|
||||
static_use_hash: true
|
||||
default_theme: simple
|
||||
theme_args:
|
||||
simple_style: dark
|
||||
|
||||
outgoing:
|
||||
request_timeout: 5.0
|
||||
max_request_timeout: 15.0
|
||||
useragent_suffix: ""
|
||||
|
||||
# Search engine configuration
|
||||
engines:
|
||||
# =====================================
|
||||
# WEB SEARCH (General)
|
||||
# =====================================
|
||||
- name: google
|
||||
engine: google
|
||||
shortcut: g
|
||||
disabled: false
|
||||
weight: 1.2
|
||||
|
||||
- name: bing
|
||||
engine: bing
|
||||
shortcut: b
|
||||
disabled: false
|
||||
weight: 1.0
|
||||
|
||||
- name: duckduckgo
|
||||
engine: duckduckgo
|
||||
shortcut: d
|
||||
disabled: false
|
||||
weight: 0.9
|
||||
|
||||
- name: brave
|
||||
engine: brave
|
||||
shortcut: br
|
||||
disabled: false
|
||||
weight: 1.0
|
||||
|
||||
- name: qwant
|
||||
engine: qwant
|
||||
shortcut: q
|
||||
disabled: false
|
||||
weight: 0.8
|
||||
|
||||
- name: startpage
|
||||
engine: startpage
|
||||
shortcut: sp
|
||||
disabled: false
|
||||
weight: 0.8
|
||||
|
||||
# =====================================
|
||||
# WIKIPEDIA
|
||||
# =====================================
|
||||
- name: wikipedia
|
||||
engine: wikipedia
|
||||
shortcut: w
|
||||
disabled: false
|
||||
weight: 1.1
|
||||
|
||||
- name: wikidata
|
||||
engine: wikidata
|
||||
shortcut: wd
|
||||
disabled: false
|
||||
weight: 0.8
|
||||
|
||||
# =====================================
|
||||
# IT / DEVELOPER
|
||||
# =====================================
|
||||
- name: github
|
||||
engine: github
|
||||
shortcut: gh
|
||||
disabled: false
|
||||
categories: [it]
|
||||
|
||||
- name: stackoverflow
|
||||
engine: stackoverflow
|
||||
shortcut: so
|
||||
disabled: false
|
||||
categories: [it]
|
||||
|
||||
- name: npm
|
||||
engine: npm
|
||||
shortcut: npm
|
||||
disabled: false
|
||||
categories: [it, packages]
|
||||
|
||||
- name: pypi
|
||||
engine: pypi
|
||||
shortcut: pip
|
||||
disabled: false
|
||||
categories: [it, packages]
|
||||
|
||||
- name: crates.io
|
||||
engine: crates
|
||||
shortcut: crates
|
||||
disabled: false
|
||||
categories: [it, packages]
|
||||
|
||||
- name: dockerhub
|
||||
engine: dockerhub
|
||||
shortcut: dh
|
||||
disabled: false
|
||||
categories: [it]
|
||||
|
||||
- name: mdn
|
||||
engine: mdn
|
||||
shortcut: mdn
|
||||
disabled: false
|
||||
categories: [it]
|
||||
|
||||
# =====================================
|
||||
# SCIENCE / ACADEMIC
|
||||
# =====================================
|
||||
- name: arxiv
|
||||
engine: arxiv
|
||||
shortcut: ar
|
||||
disabled: false
|
||||
categories: [science]
|
||||
|
||||
- name: google scholar
|
||||
engine: google_scholar
|
||||
shortcut: gs
|
||||
disabled: false
|
||||
categories: [science]
|
||||
|
||||
- name: semantic scholar
|
||||
engine: semantic_scholar
|
||||
shortcut: ss
|
||||
disabled: false
|
||||
categories: [science]
|
||||
|
||||
- name: pubmed
|
||||
engine: pubmed
|
||||
shortcut: pm
|
||||
disabled: false
|
||||
categories: [science, health]
|
||||
|
||||
- name: crossref
|
||||
engine: crossref
|
||||
shortcut: cr
|
||||
disabled: false
|
||||
categories: [science]
|
||||
|
||||
# =====================================
|
||||
# NEWS
|
||||
# =====================================
|
||||
- name: google news
|
||||
engine: google_news
|
||||
shortcut: gn
|
||||
disabled: false
|
||||
categories: [news]
|
||||
|
||||
- name: bing news
|
||||
engine: bing_news
|
||||
shortcut: bn
|
||||
disabled: false
|
||||
categories: [news]
|
||||
|
||||
- name: duckduckgo news
|
||||
engine: duckduckgo
|
||||
shortcut: ddn
|
||||
disabled: false
|
||||
categories: [news]
|
||||
|
||||
# =====================================
|
||||
# IMAGES
|
||||
# =====================================
|
||||
- name: google images
|
||||
engine: google_images
|
||||
shortcut: gi
|
||||
disabled: false
|
||||
categories: [images]
|
||||
|
||||
- name: bing images
|
||||
engine: bing_images
|
||||
shortcut: bi
|
||||
disabled: false
|
||||
categories: [images]
|
||||
|
||||
- name: unsplash
|
||||
engine: unsplash
|
||||
shortcut: us
|
||||
disabled: false
|
||||
categories: [images]
|
||||
|
||||
# =====================================
|
||||
# VIDEOS
|
||||
# =====================================
|
||||
- name: youtube
|
||||
engine: youtube_noapi
|
||||
shortcut: yt
|
||||
disabled: false
|
||||
categories: [videos]
|
||||
|
||||
- name: vimeo
|
||||
engine: vimeo
|
||||
shortcut: vim
|
||||
disabled: false
|
||||
categories: [videos]
|
||||
|
||||
- name: peertube
|
||||
engine: peertube
|
||||
shortcut: pt
|
||||
disabled: false
|
||||
categories: [videos]
|
||||
|
||||
# Category tabs
|
||||
categories_as_tabs:
|
||||
general:
|
||||
images:
|
||||
videos:
|
||||
news:
|
||||
science:
|
||||
it:
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue