diff --git a/apps/web_v1/scripts/structure-check.mjs b/apps/web_v1/scripts/structure-check.mjs
index 9256619..4ff2cb0 100644
--- a/apps/web_v1/scripts/structure-check.mjs
+++ b/apps/web_v1/scripts/structure-check.mjs
@@ -21,6 +21,8 @@ const requiredFiles = [
'src/api/client.ts',
'src/api/projects.ts',
'src/api/mappers.tsx',
+ 'src/api/search.ts',
+ 'src/api/inbox.ts',
]
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
@@ -54,6 +56,47 @@ if (existsSync('src/app/App.tsx')) {
}
}
+const apiFiles = existsSync('src/api')
+ ? ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts']
+ .map((name) => `src/api/${name}`)
+ .filter((file) => existsSync(file))
+ : []
+const apiSource = apiFiles.map((file) => readFileSync(file, 'utf8')).join('\n')
+for (const forbidden of [
+ { pattern: /\bID\s*\?\s*:/, label: 'optional PascalCase ID compatibility field' },
+ { pattern: /\bName\s*\?\s*:/, label: 'optional PascalCase Name compatibility field' },
+ { pattern: /['"`]\/api\/projects/, label: 'legacy /api/projects path' },
+ {
+ pattern: /\b(?:project|task|inbox)Id\s*\??\s*:\s*(?:string\s*\|\s*)?number\b/i,
+ label: 'numeric project/task/inbox identity',
+ },
+]) {
+ if (forbidden.pattern.test(apiSource)) failures.push(`src/api contains ${forbidden.label}`)
+}
+
+const clientSource = existsSync('src/api/client.ts') ? readFileSync('src/api/client.ts', 'utf8') : ''
+if (!clientSource.includes("'/api/v1/auth/login'")) failures.push('login must use /api/v1/auth/login')
+if (!clientSource.includes('export class ApiError extends Error')) failures.push('client must export ApiError')
+
+const projectsSource = existsSync('src/api/projects.ts') ? readFileSync('src/api/projects.ts', 'utf8') : ''
+if (!projectsSource.includes("'/api/v1/projects'")) failures.push('project API must use /api/v1/projects')
+if (/['"`]\/api\/(?!v1\/)/.test(apiSource)) failures.push('src/api paths must use the /api/v1 prefix')
+
+for (const match of apiSource.matchAll(/export type (\w+DTO)\s*=\s*\{([\s\S]*?)\n\}/g)) {
+ const [, name, body] = match
+ if (/^\s*\w+\s*\?\s*:/m.test(body)) failures.push(`${name} fields must be required`)
+ if (/^\s*[A-Z]\w*\s*:/m.test(body)) failures.push(`${name} fields must use camelCase`)
+ if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`)
+}
+
+const searchSource = existsSync('src/api/search.ts') ? readFileSync('src/api/search.ts', 'utf8') : ''
+if (searchSource && !searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search')
+
+const inboxSource = existsSync('src/api/inbox.ts') ? readFileSync('src/api/inbox.ts', 'utf8') : ''
+for (const path of ['/api/v1/projects/', '/api/v1/inbox/']) {
+ if (inboxSource && !inboxSource.includes(path)) failures.push(`inbox API must use ${path}`)
+}
+
const html = readFileSync('index.html', 'utf8')
if (!html.includes('')) failures.push('index language must be zh-CN')
if (!html.includes('
森林AI')) failures.push('document title must be 森林AI')
diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs
index 437a6de..d2b5de9 100644
--- a/apps/web_v1/scripts/visual-check.mjs
+++ b/apps/web_v1/scripts/visual-check.mjs
@@ -18,13 +18,14 @@ const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 })
const errors = []
const failures = []
+const projectId = '019b0000-0000-7000-8000-000000000001'
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
const visualCheckWorkspace = {
project: {
- id: 1,
+ id: projectId,
name: '森林项目',
identifier: 'forest',
icon: '森',
@@ -34,15 +35,15 @@ const visualCheckWorkspace = {
unreadCount: 2,
},
channels: [
- { id: 'overview', projectID: 1, type: 'overview', title: '概况', icon: 'home', sortOrder: 0 },
- { id: 'tasks', projectID: 1, type: 'tasks', title: '工作计划', icon: 'list', count: 1, sortOrder: 1 },
- { id: 'ai', projectID: 1, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', sortOrder: 2 },
- { id: 'notes', projectID: 1, type: 'notes_sources', title: '笔记资料', icon: 'file', sortOrder: 3 },
- { id: 'cron', projectID: 1, type: 'cron', title: '计划任务', icon: 'clock', sortOrder: 4 },
+ { id: 'overview', projectId, type: 'overview', title: '概况', icon: 'home', count: 0, url: '', sortOrder: 0 },
+ { id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 1, url: '', sortOrder: 1 },
+ { id: 'ai', projectId, type: 'ai_sessions', title: 'AI 助手', icon: 'robot', count: 0, url: '', sortOrder: 2 },
+ { id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 3 },
+ { id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 4 },
],
- tags: ['all', '产品'],
+ tags: [{ id: '019b0000-0000-7000-8000-000000000002', name: '产品' }],
recentSessions: [
- { id: 'session-1', title: '移动导航会话', summary: '检查抽屉关闭行为', updatedAt: '今天 10:00', references: [] },
+ { id: 'session-1', projectId, title: '移动导航会话', summary: '检查抽屉关闭行为', updatedAt: '2026-07-21T02:00:00Z', references: [] },
],
inbox: [],
tasks: [],
@@ -51,25 +52,25 @@ const visualCheckWorkspace = {
cronPlans: [],
}
-await page.route('http://localhost:9150/api/**', async (route) => {
+await page.route('http://localhost:9150/api/v1/**', async (route) => {
const url = new URL(route.request().url())
- if (url.pathname === '/api/status') {
- await route.fulfill({ json: { timestamp_ms: Date.now() } })
+ if (url.pathname === '/api/v1/status') {
+ await route.fulfill({ json: { timestamp: new Date().toISOString() } })
return
}
- if (url.pathname === '/api/auth/login') {
+ if (url.pathname === '/api/v1/auth/login') {
await route.fulfill({ json: { token: 'visual-check-token' } })
return
}
- if (url.pathname === '/api/projects') {
- await route.fulfill({ json: [{ ID: 1, Name: visualCheckWorkspace.project.name }] })
+ if (url.pathname === '/api/v1/projects') {
+ await route.fulfill({ json: [visualCheckWorkspace.project] })
return
}
- if (url.pathname === '/api/projects/1/workspace') {
+ if (url.pathname === `/api/v1/projects/${projectId}/workspace`) {
await route.fulfill({ json: visualCheckWorkspace })
return
}
- await route.fulfill({ status: 404, json: { error: `unmocked visual check route: ${url.pathname}` } })
+ await route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '视觉检查未配置该接口' } } })
})
const collectLoginMetrics = async () => page.evaluate(() => {
diff --git a/apps/web_v1/src/api/client.ts b/apps/web_v1/src/api/client.ts
index 1bcb760..b0ff3d5 100644
--- a/apps/web_v1/src/api/client.ts
+++ b/apps/web_v1/src/api/client.ts
@@ -9,6 +9,25 @@ type RequestOptions = {
token?: string
}
+type ErrorEnvelope = {
+ error: {
+ code: string
+ message: string
+ }
+}
+
+export class ApiError extends Error {
+ status: number
+ code: string
+
+ constructor(status: number, code: string, message: string) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ this.code = code
+ }
+}
+
let configuredBaseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:18080')
export function setApiBaseUrl(baseUrl: string) {
@@ -20,7 +39,7 @@ export function getApiBaseUrl() {
}
export async function login(email: string, password: string): Promise {
- const response = await apiRequest<{ token: string }>('/api/auth/login', {
+ const response = await apiRequest<{ token: string }>('/api/v1/auth/login', {
method: 'POST',
body: { email, password },
})
@@ -30,24 +49,30 @@ export async function login(email: string, password: string): Promise(path: string, options: RequestOptions = {}): Promise {
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
const requestBody = options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body)
- const response = await fetch(`${configuredBaseUrl}${path}`, {
- method: options.method ?? 'GET',
- headers: {
- ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
- ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
- },
- body: requestBody as BodyInit | undefined,
- })
+ let response: Response
+ try {
+ response = await fetch(`${configuredBaseUrl}${path}`, {
+ method: options.method ?? 'GET',
+ headers: {
+ ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
+ ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
+ },
+ body: requestBody as BodyInit | undefined,
+ })
+ } catch {
+ throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
+ }
if (!response.ok) {
- let message = `请求失败:${response.status}`
+ let envelope: ErrorEnvelope | null = null
try {
- const payload = await response.json()
- if (typeof payload.error === 'string') message = payload.error
+ const payload: unknown = await response.json()
+ if (isErrorEnvelope(payload)) envelope = payload
} catch {
- // Keep the status-based message when the server does not return JSON.
+ // 非 JSON 响应统一使用状态码文案,避免把服务端内部信息展示给用户。
}
- throw new Error(message)
+ if (envelope) throw new ApiError(response.status, envelope.error.code, envelope.error.message)
+ throw new ApiError(response.status, fallbackErrorCode(response.status), fallbackErrorMessage(response.status))
}
if (response.status === 204) {
@@ -61,6 +86,39 @@ export async function apiRequest(path: string, options: RequestOptions = {}):
return JSON.parse(text) as T
}
+function isErrorEnvelope(value: unknown): value is ErrorEnvelope {
+ if (!value || typeof value !== 'object' || !('error' in value)) return false
+ const error = value.error
+ return (
+ !!error &&
+ typeof error === 'object' &&
+ 'code' in error &&
+ typeof error.code === 'string' &&
+ error.code.trim() !== '' &&
+ 'message' in error &&
+ typeof error.message === 'string' &&
+ error.message.trim() !== ''
+ )
+}
+
+function fallbackErrorCode(status: number) {
+ if (status === 401) return 'unauthorized'
+ if (status === 403) return 'forbidden'
+ if (status === 404) return 'not_found'
+ if (status === 409) return 'conflict'
+ if (status >= 500) return 'internal_error'
+ return 'request_failed'
+}
+
+function fallbackErrorMessage(status: number) {
+ if (status === 401) return '登录已失效,请重新登录'
+ if (status === 403) return '没有权限执行此操作'
+ if (status === 404) return '请求的内容不存在'
+ if (status === 409) return '操作冲突,请刷新后重试'
+ if (status >= 500) return '服务器暂时无法处理请求,请稍后重试'
+ return '请求失败,请稍后重试'
+}
+
function normalizeBaseUrl(value: string) {
const trimmed = value.trim()
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
diff --git a/apps/web_v1/src/api/inbox.ts b/apps/web_v1/src/api/inbox.ts
index f09e413..6421d32 100644
--- a/apps/web_v1/src/api/inbox.ts
+++ b/apps/web_v1/src/api/inbox.ts
@@ -1,21 +1,24 @@
import { apiRequest, type ApiSession } from './client'
-export type BackendInboxItem = {
- id?: number
- ID?: number
- project_id?: number
- projectID?: number
- source_type?: string
- sourceType?: string
- title?: string
- body?: string
- status?: string
-}
-
-export type InboxSuggestion = {
- type: string
+export type InboxItemDTO = {
+ id: string
+ projectId: string
+ sourceType: string
title: string
body: string
+ status: string
+ createdAt: string
+ updatedAt: string
+}
+
+export type InboxSuggestionDTO = {
+ kind: 'task' | 'note' | 'source'
+ title: string
+ body: string
+}
+
+export type AnalyzeInboxResponseDTO = {
+ suggestions: InboxSuggestionDTO[]
}
export type CaptureInboxInput = {
@@ -24,27 +27,23 @@ export type CaptureInboxInput = {
body: string
}
-export async function captureProjectInbox(session: ApiSession, projectId: string | number, input: CaptureInboxInput) {
- return apiRequest(`/api/projects/${projectId}/inbox`, {
+export async function captureProjectInbox(session: ApiSession, projectId: string, input: CaptureInboxInput) {
+ return apiRequest(`/api/v1/projects/${projectId}/inbox`, {
method: 'POST',
token: session.token,
- body: {
- source_type: input.sourceType,
- title: input.title,
- body: input.body,
- },
+ body: input,
})
}
-export async function analyzeInboxItem(session: ApiSession, inboxId: string | number) {
- return apiRequest<{ suggestions: InboxSuggestion[] }>(`/api/inbox/${inboxId}/analyze`, {
+export async function analyzeInboxItem(session: ApiSession, inboxId: string) {
+ return apiRequest(`/api/v1/inbox/${inboxId}/analyze`, {
method: 'POST',
token: session.token,
})
}
-export async function confirmInboxItem(session: ApiSession, inboxId: string | number, suggestions: InboxSuggestion[]) {
- return apiRequest(`/api/inbox/${inboxId}/confirm`, {
+export async function confirmInboxItem(session: ApiSession, inboxId: string, suggestions: InboxSuggestionDTO[]) {
+ return apiRequest(`/api/v1/inbox/${inboxId}/confirm`, {
method: 'POST',
token: session.token,
body: { suggestions },
diff --git a/apps/web_v1/src/api/mappers.tsx b/apps/web_v1/src/api/mappers.tsx
index 426d8ef..e81122b 100644
--- a/apps/web_v1/src/api/mappers.tsx
+++ b/apps/web_v1/src/api/mappers.tsx
@@ -1,20 +1,20 @@
import { IconClockCircle, IconEmail, IconFile, IconHome, IconLink, IconList, IconRobot, IconUserGroup } from '@arco-design/web-react/icon'
import type {
- BackendAISession,
- BackendChannel,
- BackendCronPlan,
- BackendInboxMessage,
- BackendNoteSource,
- BackendProjectWorkspace,
- BackendTask,
+ ProjectWorkspaceDTO,
+ WorkspaceAISessionDTO,
+ WorkspaceChannelDTO,
+ WorkspaceCronPlanDTO,
+ WorkspaceInboxDTO,
+ WorkspaceNoteSourceDTO,
+ WorkspaceTaskDTO,
} from './projects'
import type { AISession, ChannelKey, CronJob, InboxItem, NoteSource, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types'
const projectColors = ['#165DFF', '#00B42A', '#722ED1', '#FF7D00', '#14C9C9', '#F53F3F']
-export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): ProjectWorkspace {
+export function mapWorkspace(payload: ProjectWorkspaceDTO, index = 0): ProjectWorkspace {
const project: Project = {
- id: String(payload.project.id),
+ id: payload.project.id,
name: payload.project.name,
identifier: payload.project.identifier ?? '',
icon: payload.project.icon ?? '',
@@ -29,7 +29,7 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
return {
project,
channels: payload.channels.filter((channel) => channel.type !== 'inbox').map(mapChannel),
- tags: payload.tags.map((tag) => (tag === 'all' ? '全部' : tag)),
+ tags: payload.tags.map((tag) => tag.name),
recentSessions: payload.recentSessions.map(mapAISession),
inbox: payload.inbox.map(mapInbox),
tasks: payload.tasks.map(mapTask),
@@ -55,7 +55,7 @@ function projectColor(background: string, index: number) {
return projectColors[index % projectColors.length]
}
-function mapChannel(channel: BackendChannel): ProjectChannel {
+function mapChannel(channel: WorkspaceChannelDTO): ProjectChannel {
return {
id: channel.id,
key: mapChannelKey(channel.type, channel.id),
@@ -86,7 +86,7 @@ function mapChannelKey(type: string, id = ''): ChannelKey {
}
}
-function channelLabel(channel: BackendChannel) {
+function channelLabel(channel: WorkspaceChannelDTO) {
switch (channel.type) {
case 'overview':
return '概况'
@@ -105,7 +105,7 @@ function channelLabel(channel: BackendChannel) {
}
}
-function channelIcon(channel: BackendChannel) {
+function channelIcon(channel: WorkspaceChannelDTO) {
switch (channel.type) {
case 'overview':
return
@@ -124,36 +124,36 @@ function channelIcon(channel: BackendChannel) {
}
}
-function mapInbox(item: BackendInboxMessage): InboxItem {
+function mapInbox(item: WorkspaceInboxDTO): InboxItem {
return {
id: item.id,
title: item.title,
meta: `来源:${item.source}`,
owner: '系统',
- time: item.time,
+ time: formatBackendDate(item.time),
tag: item.tag || item.status,
summary: item.summary,
status: item.status,
}
}
-function mapTask(task: BackendTask): TaskItem {
+function mapTask(task: WorkspaceTaskDTO): TaskItem {
return {
id: task.id,
title: task.title,
owner: task.owner,
- progress: task.completed ? '100%' : '60%',
- due: task.due || '未设置',
- createdAt: formatCreatedAt(task.createdAt),
- completedAt: task.completedAt ? formatCreatedAt(task.completedAt) : '',
- duration: task.duration || '',
+ progress: task.completed ? '100%' : '',
+ due: task.due ? formatBackendDate(task.due) : '未设置',
+ createdAt: formatBackendDate(task.createdAt),
+ completedAt: task.completedAt ? formatBackendDate(task.completedAt) : '',
+ duration: '',
tag: task.tag || '',
summary: task.summary,
completed: task.completed,
}
}
-function formatCreatedAt(value: string) {
+function formatBackendDate(value: string) {
const date = parseBackendDate(value)
if (!date) return '未知时间'
@@ -184,35 +184,35 @@ function pad(value: number) {
return String(value).padStart(2, '0')
}
-function mapAISession(session: BackendAISession): AISession {
+function mapAISession(session: WorkspaceAISessionDTO): AISession {
return {
id: session.id,
title: session.title,
summary: session.summary,
owner: 'AI',
- time: session.updatedAt,
+ time: formatBackendDate(session.updatedAt),
}
}
-function mapNoteSource(note: BackendNoteSource): NoteSource {
+function mapNoteSource(note: WorkspaceNoteSourceDTO): NoteSource {
return {
id: note.id,
title: note.title,
type: note.kind === 'note' ? '笔记' : note.source,
- updated: note.updatedAt,
+ updated: formatBackendDate(note.updatedAt),
owner: note.source,
source: note.source,
}
}
-function mapCronPlan(plan: BackendCronPlan): CronJob {
+function mapCronPlan(plan: WorkspaceCronPlanDTO): CronJob {
return {
id: plan.id,
name: plan.title,
expr: plan.schedule,
status: plan.enabled ? '运行中' : '暂停',
lastRun: plan.lastResult,
- nextRun: plan.nextRun || '暂停中',
+ nextRun: plan.nextRun ? formatBackendDate(plan.nextRun) : '暂停中',
owner: plan.owner,
enabled: plan.enabled,
}
diff --git a/apps/web_v1/src/api/projects.ts b/apps/web_v1/src/api/projects.ts
index 7217a7f..500cc10 100644
--- a/apps/web_v1/src/api/projects.ts
+++ b/apps/web_v1/src/api/projects.ts
@@ -1,44 +1,44 @@
import { apiRequest, type ApiSession } from './client'
-export type BackendProject = {
- ID?: number
- id?: number
- Name?: string
- name?: string
- Identifier?: string
- identifier?: string
- Icon?: string
- icon?: string
- Background?: string
- background?: string
- Description?: string
- description?: string
+export type ProjectDTO = {
+ id: string
+ name: string
+ identifier: string
+ icon: string
+ background: string
+ description: string
}
-export type BackendWorkspaceProject = {
- id: number
+export type WorkspaceProjectDTO = {
+ id: string
name: string
- identifier?: string
- icon?: string
- background?: string
+ identifier: string
+ icon: string
+ background: string
description: string
initials: string
unreadCount: number
}
-export type BackendChannel = {
+export type WorkspaceChannelDTO = {
id: string
- projectID: number
+ projectId: string
type: string
title: string
icon: string
- count?: number
- url?: string
+ count: number | undefined
+ url: string | undefined
sortOrder: number
}
-export type BackendInboxMessage = {
+export type WorkspaceTagDTO = {
id: string
+ name: string
+}
+
+export type WorkspaceInboxDTO = {
+ id: string
+ projectId: string
source: string
title: string
summary: string
@@ -47,29 +47,32 @@ export type BackendInboxMessage = {
time: string
}
-export type BackendTask = {
+export type WorkspaceTaskDTO = {
id: string
+ projectId: string
title: string
summary: string
completed: boolean
owner: string
- due: string
+ due: string | null
createdAt: string
- completedAt: string
- duration: string
+ completedAt: string | null
+ tagId: string | null
tag: string
}
-export type BackendAISession = {
+export type WorkspaceAISessionDTO = {
id: string
+ projectId: string
title: string
summary: string
updatedAt: string
references: string[]
}
-export type BackendNoteSource = {
+export type WorkspaceNoteSourceDTO = {
id: string
+ projectId: string
kind: string
title: string
updatedAt: string
@@ -77,55 +80,77 @@ export type BackendNoteSource = {
source: string
}
-export type BackendCronPlan = {
+export type WorkspaceCronPlanDTO = {
id: string
+ projectId: string
title: string
schedule: string
- nextRun: string
+ nextRun: string | null
enabled: boolean
lastResult: string
owner: string
}
-export type BackendProjectWorkspace = {
- project: BackendWorkspaceProject
- channels: BackendChannel[]
- tags: string[]
- recentSessions: BackendAISession[]
- inbox: BackendInboxMessage[]
- tasks: BackendTask[]
- aiSessions: BackendAISession[]
- notesSources: BackendNoteSource[]
- cronPlans: BackendCronPlan[]
+export type ProjectWorkspaceDTO = {
+ project: WorkspaceProjectDTO
+ channels: WorkspaceChannelDTO[]
+ tags: WorkspaceTagDTO[]
+ recentSessions: WorkspaceAISessionDTO[]
+ inbox: WorkspaceInboxDTO[]
+ tasks: WorkspaceTaskDTO[]
+ aiSessions: WorkspaceAISessionDTO[]
+ notesSources: WorkspaceNoteSourceDTO[]
+ cronPlans: WorkspaceCronPlanDTO[]
}
-export type BackendTag = {
- ID?: number
- id?: number
- Name?: string
- name?: string
+export type TaskDTO = {
+ id: string
+ projectId: string
+ title: string
+ description: string
+ status: string
+ completed: boolean
+ dueAt: string | null
+ assigneeId: string | null
+ tagId: string | null
+ tag: string
+ sourceInboxItemId: string | null
+ createdAt: string
+ updatedAt: string
}
-export async function fetchProjects(session: ApiSession) {
- return apiRequest('/api/projects', { token: session.token })
+export type SourceDTO = {
+ id: string
+ projectId: string
+ kind: string
+ title: string
+ filePath: string
+ createdAt: string
+ updatedAt: string
}
-export async function fetchProjectWorkspace(session: ApiSession, projectId: string | number) {
- return apiRequest(`/api/projects/${projectId}/workspace`, { token: session.token })
-}
-
-export async function fetchProjectTags(session: ApiSession, projectId: string | number) {
- return apiRequest(`/api/projects/${projectId}/tags`, { token: session.token })
+export type CronPlanDTO = {
+ id: string
+ projectId: string
+ title: string
+ schedule: string
+ nextRunAt: string | null
+ enabled: boolean
+ lastResult: string
+ createdAt: string
+ updatedAt: string
}
export type CreateProjectInput = {
name: string
- identifier?: string
- icon?: string
- background?: string
- description?: string
+ identifier: string
+ icon: string
+ background: string
+ description: string
}
+export type UpdateProjectInput = Partial
+
export type CreateTaskInput = {
title: string
description?: string
@@ -137,8 +162,9 @@ export type CreateTaskInput = {
export type UpdateTaskInput = {
title: string
description?: string
+ status?: string
completed: boolean
- nextProjectId?: number
+ nextProjectId?: string
tag?: string
}
@@ -158,51 +184,71 @@ export type CreateProjectTagInput = {
name: string
}
+export async function fetchProjects(session: ApiSession) {
+ return apiRequest('/api/v1/projects', { token: session.token })
+}
+
+export async function fetchProjectWorkspace(session: ApiSession, projectId: string) {
+ return apiRequest(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
+}
+
+export async function fetchProjectTags(session: ApiSession, projectId: string) {
+ return apiRequest(`/api/v1/projects/${projectId}/tags`, { token: session.token })
+}
+
export async function createProject(session: ApiSession, input: CreateProjectInput) {
- return apiRequest('/api/projects', {
+ return apiRequest('/api/v1/projects', {
method: 'POST',
token: session.token,
body: input,
})
}
-export async function createTask(session: ApiSession, projectId: string | number, input: CreateTaskInput) {
- return apiRequest(`/api/projects/${projectId}/tasks`, {
- method: 'POST',
- token: session.token,
- body: input,
- })
-}
-
-export async function updateTask(session: ApiSession, projectId: string | number, taskId: string | number, input: UpdateTaskInput) {
- return apiRequest(`/api/projects/${projectId}/tasks/${taskId}`, {
+export async function updateProject(session: ApiSession, projectId: string, input: UpdateProjectInput) {
+ return apiRequest(`/api/v1/projects/${projectId}`, {
method: 'PATCH',
token: session.token,
body: input,
})
}
-export async function uploadSource(session: ApiSession, projectId: string | number, input: UploadSourceInput) {
+export async function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) {
+ return apiRequest(`/api/v1/projects/${projectId}/tasks`, {
+ method: 'POST',
+ token: session.token,
+ body: input,
+ })
+}
+
+export async function updateTask(session: ApiSession, projectId: string, taskId: string, input: UpdateTaskInput) {
+ return apiRequest(`/api/v1/projects/${projectId}/tasks/${taskId}`, {
+ method: 'PATCH',
+ token: session.token,
+ body: input,
+ })
+}
+
+export async function uploadSource(session: ApiSession, projectId: string, input: UploadSourceInput) {
const body = new FormData()
body.append('file', input.file)
if (input.title) body.append('title', input.title)
- return apiRequest(`/api/projects/${projectId}/sources`, {
+ return apiRequest(`/api/v1/projects/${projectId}/sources`, {
method: 'POST',
token: session.token,
body,
})
}
-export async function createCronPlan(session: ApiSession, projectId: string | number, input: CreateCronPlanInput) {
- return apiRequest(`/api/projects/${projectId}/cron-plans`, {
+export async function createCronPlan(session: ApiSession, projectId: string, input: CreateCronPlanInput) {
+ return apiRequest(`/api/v1/projects/${projectId}/cron-plans`, {
method: 'POST',
token: session.token,
body: input,
})
}
-export async function createProjectTag(session: ApiSession, projectId: string | number, input: CreateProjectTagInput) {
- return apiRequest(`/api/projects/${projectId}/tags`, {
+export async function createProjectTag(session: ApiSession, projectId: string, input: CreateProjectTagInput) {
+ return apiRequest(`/api/v1/projects/${projectId}/tags`, {
method: 'POST',
token: session.token,
body: input,
diff --git a/apps/web_v1/src/api/search.ts b/apps/web_v1/src/api/search.ts
new file mode 100644
index 0000000..2aa402f
--- /dev/null
+++ b/apps/web_v1/src/api/search.ts
@@ -0,0 +1,20 @@
+import { apiRequest, type ApiSession } from './client'
+
+export type SearchResultDTO = {
+ type: 'project' | 'task' | 'note' | 'source' | 'inbox' | 'ai_session'
+ id: string
+ projectId: string
+ title: string
+ snippet: string
+}
+
+export type SearchResponseDTO = {
+ items: SearchResultDTO[]
+}
+
+const searchPath = '/api/v1/search'
+
+export async function searchWorkspace(session: ApiSession, query: string) {
+ const search = new URLSearchParams({ q: query })
+ return apiRequest(`${searchPath}?${search.toString()}`, { token: session.token })
+}
diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx
index 9bda1a9..d9a7f5d 100644
--- a/apps/web_v1/src/app/App.tsx
+++ b/apps/web_v1/src/app/App.tsx
@@ -42,7 +42,7 @@ function App() {
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
const backendProjects = await fetchProjects(nextSession)
const backendWorkspaces = await Promise.all(
- backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.ID ?? project.id ?? '').then((workspace) => mapWorkspace(workspace, index))),
+ backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id).then((workspace) => mapWorkspace(workspace, index))),
)
setWorkspaces(backendWorkspaces)
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === preferredProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
@@ -114,7 +114,7 @@ function App() {
background: draft.background.trim(),
description: draft.description.trim(),
})
- const nextProjectID = String(created.ID ?? created.id ?? '')
+ const nextProjectID = created.id
await refreshAfterAction(nextProjectID)
if (nextProjectID) {
setActiveProjectID(nextProjectID)
@@ -203,7 +203,7 @@ function App() {
title: update.title,
description: update.summary,
completed: update.completed,
- nextProjectId: Number(update.nextProjectId),
+ nextProjectId: update.nextProjectId,
tag: update.tag,
})
await refreshAfterAction(update.nextProjectId)
diff --git a/apps/web_v1/src/pages/login.tsx b/apps/web_v1/src/pages/login.tsx
index 93f9452..6f3ce46 100644
--- a/apps/web_v1/src/pages/login.tsx
+++ b/apps/web_v1/src/pages/login.tsx
@@ -104,10 +104,10 @@ async function checkServerStatus(
setStatus('checking')
try {
- const response = await fetch(`${baseUrl}/api/status`, { signal })
+ const response = await fetch(`${baseUrl}/api/v1/status`, { signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
- const payload = await response.json() as { timestamp_ms?: number }
- if (typeof payload.timestamp_ms !== 'number') throw new Error('invalid status payload')
+ const payload = await response.json() as { timestamp?: string }
+ if (typeof payload.timestamp !== 'string') throw new Error('invalid status payload')
setStatus('online')
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return