Compare commits
24 Commits
250b71951e
...
1fcec3e0b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fcec3e0b5 | |||
| 7b1bbf17f3 | |||
| 884148b88f | |||
| ec59dee6b9 | |||
| 2d82705679 | |||
| e289b1dbb0 | |||
| 27ff659c30 | |||
| 43d0b9f049 | |||
| ba4ca16375 | |||
| 6341a6bd8b | |||
| 7086b8b0f4 | |||
| 092930e176 | |||
| 1a82a8b53d | |||
| 3194cbed29 | |||
| 95dce81161 | |||
| 6c12d4b0db | |||
| 5b95668945 | |||
| 4fc9271896 | |||
| 8f2b8c9585 | |||
| d538627272 | |||
| 4e46fee809 | |||
| f7cd69c70f | |||
| 7800b07d42 | |||
| 62bd3d0455 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@ node_modules/
|
||||
dist/
|
||||
target/
|
||||
**/src-tauri/gen/
|
||||
backend/etc/agent.local.yaml
|
||||
.env
|
||||
data/
|
||||
coverage/
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
- 使用 Go、Gin、Gorm 和 PostgreSQL。
|
||||
- 后端模块集中放在 `backend/internal` 下,并保持职责聚焦。
|
||||
- 使用模块路径 `senlinai-agent/backend`。
|
||||
- 业务逻辑包统一放在 `backend/internal/logic` 下,例如 `ai`、`auth`、`files`、`inbox`、`notes`、`projects`、`search`、`tasks`。
|
||||
- Gorm 模型和数据库连接入口统一放在 `backend/internal/models`,不要恢复独立的 `domain` 或 `db` 包。
|
||||
- 模型结构体使用 `SenlinAgent` 前缀,数据库表名使用 `senlin_agent_` 前缀。
|
||||
- 所有服务端本地文件路径构造必须集中在文件服务中。
|
||||
- 不允许 HTTP handler 直接构造存储路径。
|
||||
- 项目标签必须限定在项目内;MVP 阶段不要引入全局标签体系。
|
||||
|
||||
98
README.md
98
README.md
@@ -1,27 +1,101 @@
|
||||
# SenlinAI Agent Workbench
|
||||
|
||||
Private project-centered workbench MVP.
|
||||
SenlinAI Agent Workbench 是一个以项目为中心的私有化工作台 MVP。
|
||||
|
||||
## Development
|
||||
## 本地开发
|
||||
|
||||
启动本地 PostgreSQL:
|
||||
|
||||
```powershell
|
||||
docker compose -f infra/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
启动后端 API:
|
||||
|
||||
```powershell
|
||||
Set-Location backend
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
## Frontend
|
||||
启动 Web 客户端:
|
||||
|
||||
The web client uses Svelte and TypeScript only. The login shell lets users enter a server IP address or domain name, which is saved as the API base URL.
|
||||
```powershell
|
||||
Set-Location apps/web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Backend Configuration
|
||||
## 后端配置
|
||||
|
||||
- `PORT`: API listen port, default `8080`.
|
||||
- `DATABASE_URL`: PostgreSQL connection string. Do not commit real credentials.
|
||||
- `AUTH_SECRET`: HMAC secret for invite and session tokens.
|
||||
- `SYSTEM_AI_KEY`: optional fallback AI provider key.
|
||||
- `AI_KEY_ENCRYPTION_SECRET`: secret used to encrypt user AI keys at rest.
|
||||
后端配置不再从多个环境变量直接读取,而是从 `backend/etc/agent.<mode>.yaml` 读取。
|
||||
|
||||
## Verification
|
||||
配置模式由环境变量 `SENLIN_APP_MODE` 决定:
|
||||
|
||||
See `docs/mvp-verification.md` for backend, web, desktop, PostgreSQL, and manual MVP verification steps.
|
||||
- 未设置 `SENLIN_APP_MODE` 时,默认读取 `backend/etc/agent.dev.yaml`。
|
||||
- 设置 `SENLIN_APP_MODE=prod` 时,读取 `backend/etc/agent.prod.yaml`。
|
||||
- 设置其他值时,按同样规则读取 `backend/etc/agent.<value>.yaml`。
|
||||
|
||||
默认开发配置文件:
|
||||
|
||||
```text
|
||||
backend/etc/agent.dev.yaml
|
||||
```
|
||||
|
||||
配置字段:
|
||||
|
||||
- `env`: 运行环境,例如 `development`。
|
||||
- `port`: API 监听端口,默认开发值为 `8080`。
|
||||
- `dsn`: PostgreSQL 连接串。不要提交真实生产或测试凭据。
|
||||
- `storage_dir`: 服务端本地文件存储目录。
|
||||
- `auth_secret`: 邀请 token 和 session token 的 HMAC 密钥。
|
||||
- `system_ai_key`: 可选的系统级 AI provider fallback key。
|
||||
- `ai_key_encryption_secret`: 用户 AI key 静态加密密钥。
|
||||
|
||||
## 前端
|
||||
|
||||
Web 客户端只使用 Svelte 和 TypeScript。登录界面允许用户输入服务器 IP 地址或域名,并将其保存为 API base URL。
|
||||
|
||||
## 桌面端
|
||||
|
||||
桌面端使用 Tauri 包装 Web 客户端。
|
||||
|
||||
构建桌面可执行文件:
|
||||
|
||||
```powershell
|
||||
Set-Location apps/desktop
|
||||
npm run build
|
||||
```
|
||||
|
||||
生成安装包:
|
||||
|
||||
```powershell
|
||||
Set-Location apps/desktop
|
||||
npm run bundle
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
后端:
|
||||
|
||||
```powershell
|
||||
Set-Location backend
|
||||
go test ./... -v
|
||||
```
|
||||
|
||||
Web:
|
||||
|
||||
```powershell
|
||||
Set-Location apps/web
|
||||
npx tsc --noEmit -p tsconfig.app.json
|
||||
npm test -- --run
|
||||
npm run build
|
||||
npx playwright test
|
||||
```
|
||||
|
||||
桌面端:
|
||||
|
||||
```powershell
|
||||
Set-Location apps/desktop
|
||||
npm run build
|
||||
```
|
||||
|
||||
更多验证说明见 `docs/mvp-verification.md`。
|
||||
|
||||
@@ -1,17 +1,79 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('project workbench shell renders', async ({ page }) => {
|
||||
test('login and project channel workbench flow', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('项目工作台')).toBeVisible();
|
||||
await expect(page.getByLabel('服务器登录')).toBeVisible();
|
||||
await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
|
||||
await expect(page.getByLabel('项目总览')).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Server IP or domain')).toBeVisible();
|
||||
await page.getByLabel('Server IP or domain').fill('localhost:8080');
|
||||
await page.getByLabel('Email or username').fill('david@example.com');
|
||||
await page.getByLabel('Password').fill('secret');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
await expect(page.getByLabel('Project list')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByRole('button', { name: 'Message Flow 36' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Work Plan 8' }).click();
|
||||
await expect(page.getByText('Tasks')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Inspect Confirm homepage information architecture' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Project A2' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(page.getByText('Ops Dashboard')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Ops Dashboard' }).click();
|
||||
await expect(page.getByText('Open external channel')).toBeVisible();
|
||||
});
|
||||
|
||||
test('inbox suggestions require explicit confirmation', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByLabel('选择 跟进报价').check();
|
||||
await page.getByRole('button', { name: '创建选中项' }).click();
|
||||
for (const server of [
|
||||
'https://user:secret@example.com',
|
||||
'http:////example.com',
|
||||
'http//example.com',
|
||||
'https//example.com',
|
||||
'http:/example.com',
|
||||
'https:/example.com',
|
||||
'http://example.com\\path',
|
||||
'http://example..com',
|
||||
'http://-bad.com',
|
||||
]) {
|
||||
test(`rejects malformed server address: ${server}`, async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('已选择 1 项')).toBeVisible();
|
||||
await page.getByLabel('Server IP or domain').fill(server);
|
||||
await page.getByLabel('Email or username').fill('david@example.com');
|
||||
await page.getByLabel('Password').fill('secret');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
await expect(page.getByText('Enter a valid HTTP or HTTPS server address.')).toBeVisible();
|
||||
await expect(page.getByLabel('Server IP or domain')).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test('mobile workbench keeps project navigation and inspector compact', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByLabel('Server IP or domain').fill('localhost:8080');
|
||||
await page.getByLabel('Email or username').fill('david@example.com');
|
||||
await page.getByLabel('Password').fill('secret');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
const rail = page.getByLabel('Project list');
|
||||
const sidebar = page.getByLabel('Project channels');
|
||||
const stage = page.getByLabel('Channel content');
|
||||
const inspector = page.getByLabel('Object inspector');
|
||||
|
||||
await expect(rail).toHaveCSS('grid-auto-flow', 'column');
|
||||
await expect(rail).toHaveCSS('max-height', '76px');
|
||||
await expect(inspector).toHaveCSS('max-height', '288px');
|
||||
|
||||
const workPlan = page.getByRole('button', { name: 'Work Plan 8' });
|
||||
await expect(workPlan).toBeVisible();
|
||||
await expect(workPlan).toBeInViewport();
|
||||
await workPlan.click();
|
||||
await expect(page.getByRole('heading', { name: 'Tasks' })).toBeVisible();
|
||||
|
||||
const [sidebarBox, stageBox] = await Promise.all([sidebar.boundingBox(), stage.boundingBox()]);
|
||||
expect(sidebarBox?.y).toBeLessThan(stageBox?.y ?? 0);
|
||||
expect((stageBox?.y ?? 0) - (sidebarBox?.y ?? 0)).toBeLessThanOrEqual(248);
|
||||
});
|
||||
|
||||
@@ -1,76 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ProjectDashboard from '../features/projects/ProjectDashboard.svelte';
|
||||
import ProjectWorkbench from '../features/workbench/ProjectWorkbench.svelte';
|
||||
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
||||
import SuggestionList from '../features/inbox/SuggestionList.svelte';
|
||||
import type { Suggestion } from '../features/inbox/types';
|
||||
import { createApi, type ProjectDashboardSummary } from '../lib/api';
|
||||
|
||||
const emptyDashboard: ProjectDashboardSummary = {
|
||||
project_id: 1,
|
||||
pending_inbox_count: 0,
|
||||
open_task_count: 0,
|
||||
recent_note_count: 0,
|
||||
recent_session_count: 0,
|
||||
};
|
||||
|
||||
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
||||
let dashboard = emptyDashboard;
|
||||
let connectionStatus = '未连接';
|
||||
let selectedSuggestionCount = 0;
|
||||
let currentUser: { account: string } | null = null;
|
||||
|
||||
const sampleSuggestions: Suggestion[] = [
|
||||
{
|
||||
kind: 'task',
|
||||
title: '跟进报价',
|
||||
body: '从项目 inbox 确认后创建任务,并保留来源记录。',
|
||||
},
|
||||
{
|
||||
kind: 'note',
|
||||
title: '客户背景',
|
||||
body: '把对话中的背景信息沉淀成项目笔记。',
|
||||
},
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
void loadDashboard();
|
||||
});
|
||||
|
||||
function handleServerChange(event: CustomEvent<{ apiBase: string }>) {
|
||||
apiBase = event.detail.apiBase;
|
||||
function handleLogin(detail: { apiBase: string; account: string }) {
|
||||
apiBase = detail.apiBase;
|
||||
localStorage.setItem('apiBase', apiBase);
|
||||
void loadDashboard();
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
connectionStatus = '连接中';
|
||||
try {
|
||||
dashboard = await createApi(apiBase).getProjectDashboard(1);
|
||||
connectionStatus = '已连接';
|
||||
} catch {
|
||||
dashboard = emptyDashboard;
|
||||
connectionStatus = '无法连接服务器';
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm(selected: Suggestion[]) {
|
||||
selectedSuggestionCount = selected.length;
|
||||
currentUser = { account: detail.account };
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="shell">
|
||||
<aside class="sidebar">
|
||||
<h1>项目工作台</h1>
|
||||
<ServerLogin {apiBase} on:serverChange={handleServerChange} />
|
||||
<p class="connection-status" aria-live="polite">{connectionStatus}</p>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<ProjectDashboard summary={dashboard} />
|
||||
<section class="inbox-review" aria-label="项目 inbox">
|
||||
<h2>AI 整理建议</h2>
|
||||
<SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} />
|
||||
<p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
{#if currentUser}
|
||||
<ProjectWorkbench {currentUser} />
|
||||
{:else}
|
||||
<ServerLogin {apiBase} onLogin={handleLogin} />
|
||||
{/if}
|
||||
|
||||
@@ -1,28 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let apiBase: string;
|
||||
export let onLogin: (detail: { apiBase: string; account: string }) => void = () => {};
|
||||
|
||||
const dispatch = createEventDispatcher<{ serverChange: { apiBase: string } }>();
|
||||
let server = apiBase;
|
||||
let account = '';
|
||||
let password = '';
|
||||
let error = '';
|
||||
let connectionStatus = 'Not connected. Enter a server address to continue.';
|
||||
|
||||
function saveServer() {
|
||||
const normalized = normalizeServer(server);
|
||||
server = normalized;
|
||||
dispatch('serverChange', { apiBase: normalized });
|
||||
function normalizeServer(value: string): { apiBase?: string; error?: string } {
|
||||
if (!value.trim()) {
|
||||
return { error: 'Enter a server address.' };
|
||||
}
|
||||
|
||||
if (hasWhitespace(value) || value.includes('\\') || hasMalformedHttpProtocol(value) || hasUnsupportedProtocol(value)) {
|
||||
return { error: 'Enter a valid HTTP or HTTPS server address.' };
|
||||
}
|
||||
|
||||
const candidate = /^https?:\/\//i.test(value) ? value : `http://${value}`;
|
||||
if (!/^https?:\/\/(?![\\/])/i.test(candidate) || hasCredentials(candidate)) {
|
||||
return { error: 'Enter a valid HTTP or HTTPS server address.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(candidate);
|
||||
if (
|
||||
!parsed.hostname ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
!['http:', 'https:'].includes(parsed.protocol) ||
|
||||
!isValidHostname(parsed.hostname)
|
||||
) {
|
||||
return { error: 'Enter a valid HTTP or HTTPS server address.' };
|
||||
}
|
||||
return { apiBase: candidate };
|
||||
} catch {
|
||||
return { error: 'Enter a valid HTTP or HTTPS server address.' };
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeServer(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
|
||||
return trimmed;
|
||||
function hasWhitespace(value: string): boolean {
|
||||
if (/\s/u.test(value)) return true;
|
||||
|
||||
try {
|
||||
return /\s/u.test(decodeURIComponent(value));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return `http://${trimmed}`;
|
||||
}
|
||||
|
||||
function hasUnsupportedProtocol(value: string): boolean {
|
||||
const explicitProtocol = value.match(/^([a-z][a-z\d+.-]*):\/\//i);
|
||||
if (explicitProtocol) {
|
||||
return !['http', 'https'].includes(explicitProtocol[1].toLowerCase());
|
||||
}
|
||||
|
||||
return /^[a-z][a-z\d+.-]*:(?!\d)/i.test(value);
|
||||
}
|
||||
|
||||
function hasMalformedHttpProtocol(value: string): boolean {
|
||||
return /^https?:\/(?!\/)/i.test(value) || /^https?\/{2}(?!\/)/i.test(value);
|
||||
}
|
||||
|
||||
function hasCredentials(value: string): boolean {
|
||||
const authority = value.replace(/^https?:\/\//i, '').split(/[/?#]/, 1)[0];
|
||||
return authority.includes('@');
|
||||
}
|
||||
|
||||
function isValidHostname(hostname: string): boolean {
|
||||
if (hostname === 'localhost') return true;
|
||||
if (/^\[[\da-f:.]+\]$/i.test(hostname)) return true;
|
||||
if (/^\d+(?:\.\d+){3}$/.test(hostname)) {
|
||||
return hostname.split('.').every((segment) => Number(segment) <= 255);
|
||||
}
|
||||
|
||||
return hostname.split('.').every((label) => /^[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(label));
|
||||
}
|
||||
|
||||
function submitLogin() {
|
||||
error = '';
|
||||
const normalized = normalizeServer(server);
|
||||
if (normalized.error) {
|
||||
error = normalized.error;
|
||||
connectionStatus = 'Check the server address before signing in.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!account.trim() || !password.trim()) {
|
||||
error = 'Enter an account and password.';
|
||||
return;
|
||||
}
|
||||
|
||||
server = normalized.apiBase ?? server;
|
||||
connectionStatus = 'Connection settings are ready.';
|
||||
onLogin({ apiBase: server, account: account.trim() });
|
||||
}
|
||||
</script>
|
||||
|
||||
<form aria-label="服务器登录" class="server-login" on:submit|preventDefault={saveServer}>
|
||||
<label for="server-address">服务器 IP 或域名</label>
|
||||
<input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
|
||||
<button type="submit">保存服务器</button>
|
||||
</form>
|
||||
<main class="login-page">
|
||||
<section class="login-panel" aria-labelledby="login-title">
|
||||
<div class="login-brand" aria-hidden="true">SA</div>
|
||||
<div class="login-heading">
|
||||
<p>Private workbench</p>
|
||||
<h1 id="login-title">SenlinAI Workbench</h1>
|
||||
</div>
|
||||
|
||||
<form class="server-login" aria-label="Server login" on:submit|preventDefault={submitLogin}>
|
||||
<label for="server-address">Server IP or domain</label>
|
||||
<input id="server-address" name="server" bind:value={server} placeholder="http://localhost:8080" />
|
||||
|
||||
<label for="login-account">Email or username</label>
|
||||
<input id="login-account" name="account" bind:value={account} autocomplete="username" />
|
||||
|
||||
<label for="login-password">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="form-error" aria-live="polite">{error}</p>
|
||||
{/if}
|
||||
|
||||
<p class="connection-status" role="status">{connectionStatus}</p>
|
||||
|
||||
<button type="submit">Log in</button>
|
||||
<p class="login-note">The server address is remembered on this device.</p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
102
apps/web/src/features/auth/ServerLogin.test.ts
Normal file
102
apps/web/src/features/auth/ServerLogin.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ServerLogin from './ServerLogin.svelte';
|
||||
|
||||
describe('ServerLogin', () => {
|
||||
it('renders server, account, and password fields', () => {
|
||||
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
|
||||
|
||||
expect(screen.getByLabelText('Server IP or domain')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Email or username')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Log in' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Not connected. Enter a server address to continue.');
|
||||
});
|
||||
|
||||
it('normalizes server address and emits login details', async () => {
|
||||
const onLogin = vi.fn();
|
||||
render(ServerLogin, {
|
||||
props: {
|
||||
apiBase: 'localhost:8080',
|
||||
onLogin,
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.input(screen.getByLabelText('Server IP or domain'), {
|
||||
target: { value: '10.0.0.12:8080' },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText('Email or username'), {
|
||||
target: { value: 'david@example.com' },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText('Password'), {
|
||||
target: { value: 'secret' },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||
|
||||
expect(onLogin).toHaveBeenCalledWith({
|
||||
apiBase: 'http://10.0.0.12:8080',
|
||||
account: 'david@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['localhost', 'http://localhost'],
|
||||
['localhost:8080', 'http://localhost:8080'],
|
||||
['192.168.1.20:3000', 'http://192.168.1.20:3000'],
|
||||
['example.com:8443', 'http://example.com:8443'],
|
||||
['https://example.com', 'https://example.com'],
|
||||
])('accepts and normalizes a valid server value: %s', async (server, apiBase) => {
|
||||
const onLogin = vi.fn();
|
||||
render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
|
||||
|
||||
await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
|
||||
await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
|
||||
await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||
|
||||
expect(onLogin).toHaveBeenCalledWith({ apiBase, account: 'david@example.com' });
|
||||
});
|
||||
|
||||
it('shows an error when account or password is missing', async () => {
|
||||
render(ServerLogin, { props: { apiBase: 'http://localhost:8080' } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||
|
||||
expect(screen.getByText('Enter an account and password.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['', 'Enter a server address.'],
|
||||
['http:// bad-server', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
[' https://example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['https://example.com/path with spaces', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['https://example%20.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['https://user:secret@example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['ftp://example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http:////example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http//example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['https//example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['https:/example.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['example.com\\path', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://example..com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://-bad.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://bad-.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://bad_host.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
['http://bad!.com', 'Enter a valid HTTP or HTTPS server address.'],
|
||||
])('rejects an invalid server value: %s', async (server, error) => {
|
||||
const onLogin = vi.fn();
|
||||
render(ServerLogin, { props: { apiBase: 'http://localhost:8080', onLogin } });
|
||||
|
||||
await fireEvent.input(screen.getByLabelText('Server IP or domain'), { target: { value: server } });
|
||||
await fireEvent.input(screen.getByLabelText('Email or username'), { target: { value: 'david@example.com' } });
|
||||
await fireEvent.input(screen.getByLabelText('Password'), { target: { value: 'secret' } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||
|
||||
expect(screen.getByText(error)).toBeInTheDocument();
|
||||
expect(onLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
32
apps/web/src/features/workbench/ChannelContent.svelte
Normal file
32
apps/web/src/features/workbench/ChannelContent.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { InspectorItem, ProjectWorkspace, WorkTask, WorkbenchChannel } from './types';
|
||||
import OverviewChannel from './channels/OverviewChannel.svelte';
|
||||
import InboxChannel from './channels/InboxChannel.svelte';
|
||||
import TasksChannel from './channels/TasksChannel.svelte';
|
||||
import AISessionsChannel from './channels/AISessionsChannel.svelte';
|
||||
import NotesSourcesChannel from './channels/NotesSourcesChannel.svelte';
|
||||
import CronChannel from './channels/CronChannel.svelte';
|
||||
import CustomLinkChannel from './channels/CustomLinkChannel.svelte';
|
||||
|
||||
export let workspace: ProjectWorkspace;
|
||||
export let tasks: WorkTask[] = workspace.tasks;
|
||||
export let channel: WorkbenchChannel;
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
export let onToggleTask: (taskID: string) => void = () => {};
|
||||
</script>
|
||||
|
||||
{#if channel.type === 'overview'}
|
||||
<OverviewChannel {workspace} {onInspect} />
|
||||
{:else if channel.type === 'inbox'}
|
||||
<InboxChannel messages={workspace.inbox} {onInspect} />
|
||||
{:else if channel.type === 'tasks'}
|
||||
<TasksChannel {tasks} {onInspect} onToggleTask={onToggleTask} />
|
||||
{:else if channel.type === 'ai_sessions'}
|
||||
<AISessionsChannel sessions={workspace.aiSessions} {onInspect} />
|
||||
{:else if channel.type === 'notes_sources'}
|
||||
<NotesSourcesChannel items={workspace.notesSources} {onInspect} />
|
||||
{:else if channel.type === 'cron'}
|
||||
<CronChannel plans={workspace.cronPlans} {onInspect} />
|
||||
{:else}
|
||||
<CustomLinkChannel {channel} />
|
||||
{/if}
|
||||
64
apps/web/src/features/workbench/ChannelContent.test.ts
Normal file
64
apps/web/src/features/workbench/ChannelContent.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ChannelContent from './ChannelContent.svelte';
|
||||
import { getProjectWorkspace } from './mockData';
|
||||
|
||||
describe('ChannelContent', () => {
|
||||
const workspace = getProjectWorkspace(1);
|
||||
|
||||
it('renders a distinctive heading or control for every channel type', () => {
|
||||
const expectedContent = {
|
||||
overview: { role: 'heading', name: 'Overview' },
|
||||
inbox: { role: 'heading', name: 'Inbox' },
|
||||
tasks: { role: 'checkbox', name: 'Mark Confirm homepage information architecture complete' },
|
||||
ai_sessions: { role: 'heading', name: 'AI Sessions' },
|
||||
notes_sources: { role: 'heading', name: 'Notes & Sources' },
|
||||
cron: { role: 'heading', name: 'Cron Plans' },
|
||||
custom_link: { role: 'link', name: 'Open external channel' },
|
||||
} as const;
|
||||
|
||||
for (const type of Object.keys(expectedContent) as Array<keyof typeof expectedContent>) {
|
||||
const channel = workspace.channels.find((item) => item.type === type);
|
||||
if (!channel) throw new Error(`missing ${type}`);
|
||||
|
||||
const { unmount } = render(ChannelContent, { props: { workspace, channel, onInspect: () => {} } });
|
||||
expect(screen.getByRole(expectedContent[type].role, { name: expectedContent[type].name })).toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('sends selected task details to inspector', async () => {
|
||||
let inspectedTitle = '';
|
||||
const channel = workspace.channels.find((item) => item.type === 'tasks');
|
||||
if (!channel) throw new Error('missing task channel');
|
||||
|
||||
render(ChannelContent, {
|
||||
props: {
|
||||
workspace,
|
||||
channel,
|
||||
onInspect: (item) => {
|
||||
inspectedTitle = item.title;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
||||
expect(inspectedTitle).toBe('Confirm homepage information architecture');
|
||||
});
|
||||
|
||||
it('reports task completion changes to its owner', async () => {
|
||||
const channel = workspace.channels.find((item) => item.type === 'tasks');
|
||||
if (!channel) throw new Error('missing task channel');
|
||||
const onToggleTask = vi.fn();
|
||||
|
||||
render(ChannelContent, { props: { workspace, channel, onInspect: () => {}, onToggleTask } });
|
||||
|
||||
const completion = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
|
||||
expect(completion).not.toBeChecked();
|
||||
|
||||
await fireEvent.click(completion);
|
||||
|
||||
expect(onToggleTask).toHaveBeenCalledWith('1-task-1');
|
||||
});
|
||||
});
|
||||
67
apps/web/src/features/workbench/ObjectInspector.svelte
Normal file
67
apps/web/src/features/workbench/ObjectInspector.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import type { InspectorItem } from './types';
|
||||
|
||||
export let item: InspectorItem | null;
|
||||
let activeTab: 'discussion' | 'properties' | 'more' = 'discussion';
|
||||
let copyFeedback = '';
|
||||
let copyFailed = false;
|
||||
let currentItem: InspectorItem | null = null;
|
||||
|
||||
$: if (item !== currentItem) {
|
||||
currentItem = item;
|
||||
copyFeedback = '';
|
||||
copyFailed = false;
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const link = new URL(`?item=${encodeURIComponent(item?.title ?? '')}`, window.location.href).toString();
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
copyFailed = true;
|
||||
copyFeedback = 'Clipboard access is unavailable.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
copyFailed = false;
|
||||
copyFeedback = 'Link copied to clipboard.';
|
||||
} catch {
|
||||
copyFailed = true;
|
||||
copyFeedback = 'Unable to copy link.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="object-inspector" aria-label="Object inspector">
|
||||
<div class="inspector-tabs">
|
||||
<button type="button" aria-pressed={activeTab === 'discussion'} on:click={() => (activeTab = 'discussion')}>Discussion</button>
|
||||
<button type="button" aria-pressed={activeTab === 'properties'} on:click={() => (activeTab = 'properties')}>Properties</button>
|
||||
<button type="button" aria-pressed={activeTab === 'more'} on:click={() => (activeTab = 'more')}>More</button>
|
||||
</div>
|
||||
|
||||
{#if item}
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.description}</p>
|
||||
{#if activeTab === 'discussion'}
|
||||
<p>No comments yet. Add project discussion here later.</p>
|
||||
{:else if activeTab === 'properties'}
|
||||
<dl>
|
||||
{#each item.properties as property}
|
||||
<div>
|
||||
<dt>{property.label}</dt>
|
||||
<dd>{property.value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{:else}
|
||||
<button type="button" on:click={copyLink}>Copy link</button>
|
||||
{#if copyFeedback}
|
||||
<p class:copy-error={copyFailed} class="copy-feedback" role="status">{copyFeedback}</p>
|
||||
{/if}
|
||||
<button type="button" aria-label="Archive is unavailable in this preview" title="Archive is unavailable in this preview" disabled>Archive</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<h2>Inspector</h2>
|
||||
<p>Select an item to inspect discussion, properties, and actions.</p>
|
||||
{/if}
|
||||
</aside>
|
||||
73
apps/web/src/features/workbench/ObjectInspector.test.ts
Normal file
73
apps/web/src/features/workbench/ObjectInspector.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import ObjectInspector from './ObjectInspector.svelte';
|
||||
|
||||
const item = {
|
||||
title: 'Review navigation',
|
||||
type: 'Task',
|
||||
description: 'Check the project navigation flow.',
|
||||
properties: [],
|
||||
};
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
|
||||
function setClipboard(writeText: Clipboard['writeText']) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText } as Clipboard,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
}
|
||||
});
|
||||
|
||||
describe('ObjectInspector', () => {
|
||||
async function openMoreActions() {
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'More' }));
|
||||
}
|
||||
|
||||
it('shows copy success only after clipboard writing resolves', async () => {
|
||||
let resolveWrite: (() => void) | undefined;
|
||||
const writeText = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveWrite = resolve;
|
||||
}),
|
||||
);
|
||||
setClipboard(writeText);
|
||||
render(ObjectInspector, { props: { item } });
|
||||
|
||||
await openMoreActions();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
||||
resolveWrite?.();
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
|
||||
});
|
||||
|
||||
it('reports a copy failure when clipboard writing rejects', async () => {
|
||||
setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
|
||||
render(ObjectInspector, { props: { item } });
|
||||
|
||||
await openMoreActions();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Unable to copy link.'));
|
||||
});
|
||||
|
||||
it('reports unavailable clipboard access without showing success', async () => {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
render(ObjectInspector, { props: { item } });
|
||||
|
||||
await openMoreActions();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy link' }));
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Clipboard access is unavailable.');
|
||||
});
|
||||
});
|
||||
67
apps/web/src/features/workbench/ProjectChannelSidebar.svelte
Normal file
67
apps/web/src/features/workbench/ProjectChannelSidebar.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import type { AISessionItem, WorkbenchChannel, WorkbenchProject } from './types';
|
||||
|
||||
export let project: WorkbenchProject;
|
||||
export let channels: WorkbenchChannel[];
|
||||
export let selectedChannelID: string;
|
||||
export let tags: string[];
|
||||
export let recentSessions: AISessionItem[];
|
||||
export let onSelectChannel: (channelID: string) => void;
|
||||
|
||||
const channelIcons: Record<WorkbenchChannel['icon'], string> = {
|
||||
home: '\u2302',
|
||||
mail: '\u2709',
|
||||
list: '\u2630',
|
||||
sparkles: '\u2726',
|
||||
file: '\u25A4',
|
||||
clock: '\u25F7',
|
||||
link: '\u21AA',
|
||||
};
|
||||
|
||||
$: sortedChannels = [...channels].sort((left, right) => left.sortOrder - right.sortOrder);
|
||||
</script>
|
||||
|
||||
<aside class="channel-sidebar" aria-label="Project channels">
|
||||
<header>
|
||||
<div>
|
||||
<p>Current project</p>
|
||||
<h2>{project.name}</h2>
|
||||
</div>
|
||||
<button aria-label="Project settings" disabled>⚙</button>
|
||||
</header>
|
||||
|
||||
<ul class="tag-row" aria-label="Project tags">
|
||||
{#each tags as tag}
|
||||
<li>#{tag}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<section class="channel-group">
|
||||
{#each sortedChannels as channel}
|
||||
<button
|
||||
class:active={channel.id === selectedChannelID}
|
||||
aria-pressed={channel.id === selectedChannelID}
|
||||
aria-label={`${channel.title}${channel.count ? ` ${channel.count}` : ''}`}
|
||||
on:click={() => onSelectChannel(channel.id)}
|
||||
>
|
||||
<span class="channel-icon" aria-hidden="true">{channelIcons[channel.icon]}</span>
|
||||
<span>{channel.title}</span>
|
||||
{#if channel.count !== undefined}
|
||||
<small>{channel.count}</small>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="recent-sessions" aria-label="Recent sessions">
|
||||
<h3>Recent sessions</h3>
|
||||
<ul>
|
||||
{#each recentSessions as session}
|
||||
<li>
|
||||
<span>{session.title}</span>
|
||||
<small>{session.updatedAt}</small>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
26
apps/web/src/features/workbench/ProjectRail.svelte
Normal file
26
apps/web/src/features/workbench/ProjectRail.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { WorkbenchProject } from './types';
|
||||
|
||||
export let projects: WorkbenchProject[];
|
||||
export let selectedProjectID: number;
|
||||
export let onSelectProject: (projectID: number) => void;
|
||||
</script>
|
||||
|
||||
<nav class="project-rail" aria-label="Project list">
|
||||
<button class="rail-logo" aria-label="Dashboard" disabled>SA</button>
|
||||
{#each projects as project}
|
||||
<button
|
||||
class:active={project.id === selectedProjectID}
|
||||
aria-pressed={project.id === selectedProjectID}
|
||||
aria-label={project.name}
|
||||
title={project.name}
|
||||
on:click={() => onSelectProject(project.id)}
|
||||
>
|
||||
<span>{project.initials}</span>
|
||||
{#if project.unreadCount}
|
||||
<small>{project.unreadCount}</small>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="add-project" aria-label="Create project" disabled>+</button>
|
||||
</nav>
|
||||
61
apps/web/src/features/workbench/ProjectWorkbench.svelte
Normal file
61
apps/web/src/features/workbench/ProjectWorkbench.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
||||
import ChannelContent from './ChannelContent.svelte';
|
||||
import ObjectInspector from './ObjectInspector.svelte';
|
||||
import ProjectRail from './ProjectRail.svelte';
|
||||
import ProjectChannelSidebar from './ProjectChannelSidebar.svelte';
|
||||
import WorkspaceTopbar from './WorkspaceTopbar.svelte';
|
||||
import type { InspectorItem, WorkTask } from './types';
|
||||
|
||||
export let currentUser: { account: string };
|
||||
|
||||
let selectedProjectID = workbenchProjects[0].id;
|
||||
let selectedItem: InspectorItem | null = null;
|
||||
let tasksByProjectID: Record<number, WorkTask[]> = Object.fromEntries(
|
||||
workbenchProjects.map((project) => [project.id, getProjectWorkspace(project.id).tasks.map((task) => ({ ...task }))]),
|
||||
);
|
||||
$: workspace = getProjectWorkspace(selectedProjectID);
|
||||
$: tasks = tasksByProjectID[selectedProjectID] ?? workspace.tasks;
|
||||
$: selectedChannelID = workspace.channels[0].id;
|
||||
$: selectedChannel = workspace.channels.find((channel) => channel.id === selectedChannelID) ?? workspace.channels[0];
|
||||
|
||||
function selectProject(projectID: number) {
|
||||
selectedItem = null;
|
||||
selectedProjectID = projectID;
|
||||
}
|
||||
|
||||
function selectChannel(channelID: string) {
|
||||
selectedItem = null;
|
||||
selectedChannelID = channelID;
|
||||
}
|
||||
|
||||
function inspectItem(item: InspectorItem) {
|
||||
selectedItem = item;
|
||||
}
|
||||
|
||||
function toggleTask(taskID: string) {
|
||||
tasksByProjectID = {
|
||||
...tasksByProjectID,
|
||||
[selectedProjectID]: tasks.map((task) => task.id === taskID ? { ...task, completed: !task.completed } : task),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="workbench-shell">
|
||||
<WorkspaceTopbar account={currentUser.account} />
|
||||
<div class="workbench-body">
|
||||
<ProjectRail projects={workbenchProjects} {selectedProjectID} onSelectProject={selectProject} />
|
||||
<ProjectChannelSidebar
|
||||
project={workspace.project}
|
||||
channels={workspace.channels}
|
||||
{selectedChannelID}
|
||||
tags={workspace.tags}
|
||||
recentSessions={workspace.recentSessions}
|
||||
onSelectChannel={selectChannel}
|
||||
/>
|
||||
<section class="channel-stage" aria-label="Channel content">
|
||||
<ChannelContent {workspace} {tasks} channel={selectedChannel} onInspect={inspectItem} onToggleTask={toggleTask} />
|
||||
</section>
|
||||
<ObjectInspector item={selectedItem} />
|
||||
</div>
|
||||
</main>
|
||||
113
apps/web/src/features/workbench/ProjectWorkbench.test.ts
Normal file
113
apps/web/src/features/workbench/ProjectWorkbench.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ProjectWorkbench from './ProjectWorkbench.svelte';
|
||||
|
||||
describe('ProjectWorkbench', () => {
|
||||
it('renders project rail, channel sidebar, topbar, and inspector', () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
expect(screen.getByLabelText('Project list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Project A1' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByLabelText('Project channels')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Message Flow 36' })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Global search')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Object inspector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes channels when switching projects', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Project A2' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByText('Ops Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks active channel with aria-pressed', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Work Plan 8' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('shows inspected task details in the inspector', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
const inspector = screen.getByLabelText('Object inspector');
|
||||
|
||||
expect(within(inspector).getByRole('button', { name: 'Discussion' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
||||
|
||||
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(within(inspector).getByRole('button', { name: 'Properties' }));
|
||||
|
||||
expect(within(inspector).getByRole('button', { name: 'Properties' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(within(inspector).getByText('Status')).toBeInTheDocument();
|
||||
expect(within(inspector).getByText('Open')).toBeInTheDocument();
|
||||
expect(within(inspector).getByText('Owner')).toBeInTheDocument();
|
||||
expect(within(inspector).getByText('David')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the inspector when the project or channel changes', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
const inspector = screen.getByLabelText('Object inspector');
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
||||
expect(within(inspector).getByRole('heading', { name: 'Confirm homepage information architecture' })).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
||||
expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
|
||||
expect(within(inspector).queryByRole('heading', { name: 'Confirm homepage information architecture' })).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
|
||||
expect(within(inspector).getByRole('heading', { name: 'Inspector' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves task completion when switching channels within a project', async () => {
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
const task = screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' });
|
||||
await fireEvent.click(task);
|
||||
expect(task).toBeChecked();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Message Flow 36' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).toBeChecked();
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Project A2' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
expect(screen.getByRole('checkbox', { name: 'Mark Confirm homepage information architecture complete' })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('provides Copy link feedback and disables unavailable archive actions', async () => {
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) } as Clipboard,
|
||||
});
|
||||
render(ProjectWorkbench, { props: { currentUser: { account: 'david@example.com' } } });
|
||||
const inspector = screen.getByLabelText('Object inspector');
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Work Plan 8' }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Inspect Confirm homepage information architecture' }));
|
||||
await fireEvent.click(within(inspector).getByRole('button', { name: 'More' }));
|
||||
await fireEvent.click(within(inspector).getByRole('button', { name: 'Copy link' }));
|
||||
|
||||
await waitFor(() => expect(within(inspector).getByRole('status')).toHaveTextContent('Link copied to clipboard.'));
|
||||
expect(within(inspector).getByRole('button', { name: 'Archive is unavailable in this preview' })).toBeDisabled();
|
||||
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
}
|
||||
});
|
||||
});
|
||||
16
apps/web/src/features/workbench/WorkspaceTopbar.svelte
Normal file
16
apps/web/src/features/workbench/WorkspaceTopbar.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
export let account: string;
|
||||
</script>
|
||||
|
||||
<header class="workspace-topbar">
|
||||
<div class="brand-mark">SenlinAI</div>
|
||||
<label class="search-box">
|
||||
<span>Search</span>
|
||||
<input aria-label="Global search" title="Global search is unavailable in this preview" placeholder="Search projects, tasks, notes, AI sessions" disabled />
|
||||
</label>
|
||||
<div class="topbar-actions">
|
||||
<button aria-label="Back" disabled>←</button>
|
||||
<button aria-label="Forward" disabled>→</button>
|
||||
<button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { AISessionItem, InspectorItem } from '../types';
|
||||
|
||||
export let sessions: AISessionItem[];
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
let selectedSessionID = sessions[0]?.id;
|
||||
|
||||
$: selectedSession = sessions.find((session) => session.id === selectedSessionID) ?? sessions[0];
|
||||
|
||||
function inspectSession(session: AISessionItem) {
|
||||
onInspect({ title: session.title, type: 'AI session', description: session.summary, properties: [{ label: 'Updated', value: session.updatedAt }, { label: 'References', value: session.references.join(', ') }] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="channel-page ai-sessions-channel">
|
||||
<header class="channel-header"><p>Project intelligence</p><h1>AI Sessions</h1></header>
|
||||
<div class="session-layout">
|
||||
<div class="session-list">
|
||||
{#each sessions as session}
|
||||
<button class:active={selectedSession?.id === session.id} on:click={() => (selectedSessionID = session.id)}>{session.title}<small>{session.updatedAt}</small></button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selectedSession}
|
||||
<article class="conversation-summary"><h2>{selectedSession.title}</h2><p>{selectedSession.summary}</p><p>References: {selectedSession.references.join(', ')}</p><button aria-label={`Inspect ${selectedSession.title}`} on:click={() => inspectSession(selectedSession)}>Inspect</button></article>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
19
apps/web/src/features/workbench/channels/CronChannel.svelte
Normal file
19
apps/web/src/features/workbench/channels/CronChannel.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { CronPlan, InspectorItem } from '../types';
|
||||
|
||||
export let plans: CronPlan[];
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
|
||||
function inspectPlan(plan: CronPlan) {
|
||||
onInspect({ title: plan.title, type: 'Cron plan', description: `Scheduled by ${plan.owner}.`, properties: [{ label: 'State', value: plan.enabled ? 'Enabled' : 'Disabled' }, { label: 'Schedule', value: plan.schedule }, { label: 'Next run', value: plan.nextRun }, { label: 'Last result', value: plan.lastResult }] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="channel-page cron-channel">
|
||||
<header class="channel-header"><p>Scheduled work</p><h1>Cron Plans</h1></header>
|
||||
<div class="cron-list">
|
||||
{#each plans as plan}
|
||||
<article class="cron-row"><div><h2>{plan.title}</h2><p>{plan.schedule} · {plan.nextRun}</p><small>{plan.enabled ? 'Enabled' : 'Disabled'} · {plan.lastResult}</small></div><button aria-label={`Inspect ${plan.title}`} on:click={() => inspectPlan(plan)}>Inspect</button></article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { WorkbenchChannel } from '../types';
|
||||
|
||||
export let channel: WorkbenchChannel;
|
||||
let copyStatus = '';
|
||||
let copyFailed = false;
|
||||
|
||||
async function copyUrl() {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
copyFailed = true;
|
||||
copyStatus = 'Copy unavailable in this browser.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(channel.url ?? '');
|
||||
copyFailed = false;
|
||||
copyStatus = 'URL copied.';
|
||||
} catch {
|
||||
copyFailed = true;
|
||||
copyStatus = 'Unable to copy URL.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="channel-page custom-link-channel">
|
||||
<header class="channel-header"><p>External resource</p><h1>{channel.title}</h1></header>
|
||||
<p>{channel.url}</p>
|
||||
<a class="custom-link" href={channel.url} target="_blank" rel="noreferrer">Open external channel</a>
|
||||
<button class="copy-url-button" type="button" on:click={copyUrl}>Copy URL</button>
|
||||
{#if copyStatus}
|
||||
<p class:copy-error={copyFailed} class="copy-feedback" aria-live="polite">{copyStatus}</p>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,70 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import CustomLinkChannel from './CustomLinkChannel.svelte';
|
||||
|
||||
const channel = {
|
||||
id: 'project-custom-link',
|
||||
projectID: 1,
|
||||
type: 'custom_link' as const,
|
||||
title: 'Roadmap Board',
|
||||
icon: 'link',
|
||||
url: 'https://example.com/roadmap',
|
||||
sortOrder: 1,
|
||||
};
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
|
||||
function setClipboard(writeText: Clipboard['writeText']) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText } as Clipboard,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
}
|
||||
});
|
||||
|
||||
describe('CustomLinkChannel', () => {
|
||||
it('shows copy success only after clipboard writing resolves', async () => {
|
||||
let resolveWrite: (() => void) | undefined;
|
||||
const writeText = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveWrite = resolve;
|
||||
}),
|
||||
);
|
||||
setClipboard(writeText);
|
||||
render(CustomLinkChannel, { props: { channel } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
|
||||
|
||||
expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
|
||||
resolveWrite?.();
|
||||
await waitFor(() => expect(screen.getByText('URL copied.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('reports a copy failure when clipboard writing rejects', async () => {
|
||||
setClipboard(vi.fn().mockRejectedValue(new Error('Permission denied')));
|
||||
render(CustomLinkChannel, { props: { channel } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Unable to copy URL.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('reports unavailable clipboard access without showing URL copied', async () => {
|
||||
Reflect.deleteProperty(navigator, 'clipboard');
|
||||
render(CustomLinkChannel, { props: { channel } });
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Copy unavailable in this browser.')).toBeInTheDocument());
|
||||
expect(screen.queryByText('URL copied.')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
22
apps/web/src/features/workbench/channels/InboxChannel.svelte
Normal file
22
apps/web/src/features/workbench/channels/InboxChannel.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { InboxMessage, InspectorItem } from '../types';
|
||||
|
||||
export let messages: InboxMessage[];
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
|
||||
function inspectMessage(message: InboxMessage) {
|
||||
onInspect({ title: message.title, type: 'Inbox message', description: message.summary, properties: [{ label: 'Source', value: message.source }, { label: 'Status', value: message.status }, { label: 'Tag', value: message.tag }, { label: 'Time', value: message.time }] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="channel-page inbox-channel">
|
||||
<header class="channel-header"><p>Message Flow</p><h1>Inbox</h1></header>
|
||||
<div class="message-list">
|
||||
{#each messages as message}
|
||||
<article class="message-row">
|
||||
<div><small>{message.source} · {message.time}</small><h2>{message.title}</h2><p>{message.summary}</p><span>{message.status} · #{message.tag}</span></div>
|
||||
<button aria-label={`Inspect ${message.title}`} on:click={() => inspectMessage(message)}>Inspect</button>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { InspectorItem, NoteSourceItem } from '../types';
|
||||
|
||||
export let items: NoteSourceItem[];
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
|
||||
function inspectItem(item: NoteSourceItem) {
|
||||
onInspect({ title: item.title, type: item.kind, description: `${item.source} record in this project.`, properties: [{ label: 'Updated', value: item.updatedAt }, { label: 'Tag', value: item.tag }, { label: 'Source', value: item.source }] });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="channel-page notes-sources-channel">
|
||||
<header class="channel-header"><p>Project reference</p><h1>Notes & Sources</h1></header>
|
||||
<div class="file-list">
|
||||
{#each items as item}
|
||||
<article class="file-row"><span>{item.kind}</span><div><h2>{item.title}</h2><small>{item.source} · {item.updatedAt} · #{item.tag}</small></div><button aria-label={`Inspect ${item.title}`} on:click={() => inspectItem(item)}>Inspect</button></article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { InspectorItem, ProjectWorkspace } from '../types';
|
||||
|
||||
export let workspace: ProjectWorkspace;
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
|
||||
const metrics = (workspace: ProjectWorkspace) => [
|
||||
{ label: 'Inbox', value: workspace.inbox.length, description: 'Messages waiting for review' },
|
||||
{ label: 'Tasks', value: workspace.tasks.length, description: 'Work plan records' },
|
||||
{ label: 'AI Sessions', value: workspace.aiSessions.length, description: 'Saved conversations' },
|
||||
{ label: 'Notes & Sources', value: workspace.notesSources.length, description: 'Project reference records' },
|
||||
{ label: 'Cron Plans', value: workspace.cronPlans.length, description: 'Scheduled task records' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="channel-page overview-channel">
|
||||
<header class="channel-header">
|
||||
<p>{workspace.project.name}</p>
|
||||
<h1>Overview</h1>
|
||||
</header>
|
||||
<div class="metric-list">
|
||||
{#each metrics(workspace) as metric}
|
||||
<button class="metric-card" on:click={() => onInspect({ title: metric.label, type: 'Workspace metric', description: metric.description, properties: [{ label: 'Records', value: String(metric.value) }] })}>
|
||||
<strong>{metric.value}</strong>
|
||||
<span>{metric.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
36
apps/web/src/features/workbench/channels/TasksChannel.svelte
Normal file
36
apps/web/src/features/workbench/channels/TasksChannel.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { InspectorItem, WorkTask } from '../types';
|
||||
|
||||
export let tasks: WorkTask[];
|
||||
export let onInspect: (item: InspectorItem) => void;
|
||||
export let onToggleTask: (taskID: string) => void = () => {};
|
||||
|
||||
function inspectTask(task: WorkTask) {
|
||||
onInspect({ title: task.title, type: 'Task', description: task.summary, properties: [{ label: 'Status', value: task.completed ? 'Completed' : 'Open' }, { label: 'Owner', value: task.owner }, { label: 'Due', value: task.due }, { label: 'Tag', value: task.tag }] });
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<section class="channel-page tasks-channel">
|
||||
<header class="channel-header"><p>Work Plan</p><h1>Tasks</h1></header>
|
||||
<div class="task-list">
|
||||
{#each tasks as task}
|
||||
<article class:completed={task.completed} class="task-card">
|
||||
<input
|
||||
class="task-check"
|
||||
type="checkbox"
|
||||
aria-label={`Mark ${task.title} complete`}
|
||||
checked={task.completed}
|
||||
on:change={() => onToggleTask(task.id)}
|
||||
/>
|
||||
<div>
|
||||
<h2>{task.title}</h2>
|
||||
<p>{task.summary}</p>
|
||||
<small>{task.owner} / {task.due} / #{task.tag}</small>
|
||||
<span class="task-status">{task.completed ? 'Completed' : 'Open'}</span>
|
||||
</div>
|
||||
<button aria-label={`Inspect ${task.title}`} on:click={() => inspectTask(task)}>Inspect</button>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
29
apps/web/src/features/workbench/mockData.test.ts
Normal file
29
apps/web/src/features/workbench/mockData.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getProjectWorkspace, workbenchProjects } from './mockData';
|
||||
|
||||
describe('workbench mock data', () => {
|
||||
it('provides dynamic projects', () => {
|
||||
expect(workbenchProjects.length).toBeGreaterThanOrEqual(2);
|
||||
expect(workbenchProjects[0]).toMatchObject({ id: expect.any(Number), name: expect.any(String) });
|
||||
});
|
||||
|
||||
it('loads project-specific channels and counts', () => {
|
||||
const first = getProjectWorkspace(workbenchProjects[0].id);
|
||||
const second = getProjectWorkspace(workbenchProjects[1].id);
|
||||
|
||||
expect(first.project.id).not.toBe(second.project.id);
|
||||
expect(first.channels.map((channel) => channel.type)).toEqual([
|
||||
'overview',
|
||||
'inbox',
|
||||
'tasks',
|
||||
'ai_sessions',
|
||||
'notes_sources',
|
||||
'cron',
|
||||
'custom_link',
|
||||
]);
|
||||
expect(first.channels.find((channel) => channel.type === 'custom_link')).toMatchObject({
|
||||
title: expect.any(String),
|
||||
url: expect.stringMatching(/^https?:\/\//),
|
||||
});
|
||||
});
|
||||
});
|
||||
61
apps/web/src/features/workbench/mockData.ts
Normal file
61
apps/web/src/features/workbench/mockData.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { ProjectWorkspace, WorkbenchChannel, WorkbenchProject } from './types';
|
||||
|
||||
export const workbenchProjects: WorkbenchProject[] = [
|
||||
{ id: 1, name: 'Project A1', description: 'Client strategy workspace', initials: 'A1', unreadCount: 36 },
|
||||
{ id: 2, name: 'Project A2', description: 'Operations planning workspace', initials: 'A2', unreadCount: 12 },
|
||||
];
|
||||
|
||||
function systemChannels(projectID: number): WorkbenchChannel[] {
|
||||
return [
|
||||
{ id: `${projectID}-overview`, projectID, type: 'overview', title: 'Overview', icon: 'home', count: 635, sortOrder: 1 },
|
||||
{ id: `${projectID}-inbox`, projectID, type: 'inbox', title: 'Message Flow', icon: 'mail', count: 36, sortOrder: 2 },
|
||||
{ id: `${projectID}-tasks`, projectID, type: 'tasks', title: 'Work Plan', icon: 'list', count: 8, sortOrder: 3 },
|
||||
{ id: `${projectID}-ai`, projectID, type: 'ai_sessions', title: 'AI Sessions', icon: 'sparkles', count: 4, sortOrder: 4 },
|
||||
{ id: `${projectID}-notes`, projectID, type: 'notes_sources', title: 'Notes & Sources', icon: 'file', count: 343, sortOrder: 5 },
|
||||
{ id: `${projectID}-cron`, projectID, type: 'cron', title: 'Cron Plans', icon: 'clock', count: 45, sortOrder: 6 },
|
||||
{
|
||||
id: `${projectID}-custom-roadmap`,
|
||||
projectID,
|
||||
type: 'custom_link',
|
||||
title: projectID === 1 ? 'Roadmap Board' : 'Ops Dashboard',
|
||||
icon: 'link',
|
||||
url: projectID === 1 ? 'https://example.com/roadmap-a1' : 'https://example.com/ops-a2',
|
||||
sortOrder: 7,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const workspaces: ProjectWorkspace[] = workbenchProjects.map((project) => ({
|
||||
project,
|
||||
channels: systemChannels(project.id),
|
||||
tags: ['all', 'tag1', 'UI', 'knowledge'],
|
||||
recentSessions: [
|
||||
{ id: `${project.id}-session-1`, title: 'Skill setup notes', summary: 'Install and verify Codex skills.', updatedAt: 'Yesterday', references: ['Notes'] },
|
||||
{ id: `${project.id}-session-2`, title: 'Pricing logic analysis', summary: 'Compare decision branches and risks.', updatedAt: '7 days ago', references: ['Tasks', 'Sources'] },
|
||||
],
|
||||
inbox: [
|
||||
{ id: `${project.id}-inbox-1`, source: 'Manual', title: 'Collect competitor pricing notes', summary: 'Turn pasted research into structured follow-up tasks.', status: 'open', tag: 'UI', time: '09:32' },
|
||||
{ id: `${project.id}-inbox-2`, source: 'AI', title: 'Meeting summary candidate', summary: 'Review suggested note before saving official object.', status: 'processed', tag: 'knowledge', time: 'Yesterday' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: `${project.id}-task-1`, title: 'Confirm homepage information architecture', summary: 'Review channel layout and right inspector behavior.', completed: false, owner: 'David', due: 'Today', tag: 'UI' },
|
||||
{ id: `${project.id}-task-2`, title: 'Archive old planning notes', summary: 'Move outdated notes into processed state.', completed: true, owner: 'Team', due: 'Yesterday', tag: 'knowledge' },
|
||||
],
|
||||
aiSessions: [
|
||||
{ id: `${project.id}-ai-1`, title: 'UI prototype critique', summary: 'Discuss Discord-like project channel behavior.', updatedAt: '10 min ago', references: ['ScreenShot.png', 'design.md'] },
|
||||
{ id: `${project.id}-ai-2`, title: 'Task sharing policy', summary: 'Validate explicit object sharing rules.', updatedAt: 'Yesterday', references: ['Tasks'] },
|
||||
],
|
||||
notesSources: [
|
||||
{ id: `${project.id}-note-1`, kind: 'note', title: 'Product workbench principles', updatedAt: 'Today', tag: 'knowledge', source: 'Markdown' },
|
||||
{ id: `${project.id}-file-1`, kind: 'file', title: 'ScreenShot.png', updatedAt: 'Today', tag: 'UI', source: 'Attachment' },
|
||||
{ id: `${project.id}-link-1`, kind: 'link', title: 'Reference board', updatedAt: '7 days ago', tag: 'tag1', source: 'URL' },
|
||||
],
|
||||
cronPlans: [
|
||||
{ id: `${project.id}-cron-1`, title: 'Weekly inbox review reminder', schedule: '0 9 * * 1', nextRun: 'Next Monday 09:00', enabled: true, lastResult: 'Not run yet', owner: 'David' },
|
||||
{ id: `${project.id}-cron-2`, title: 'Monthly source cleanup', schedule: '0 10 1 * *', nextRun: 'Next month', enabled: false, lastResult: 'Paused', owner: 'Team' },
|
||||
],
|
||||
}));
|
||||
|
||||
export function getProjectWorkspace(projectID: number): ProjectWorkspace {
|
||||
return workspaces.find((workspace) => workspace.project.id === projectID) ?? workspaces[0];
|
||||
}
|
||||
93
apps/web/src/features/workbench/types.ts
Normal file
93
apps/web/src/features/workbench/types.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export type ChannelType =
|
||||
| 'overview'
|
||||
| 'inbox'
|
||||
| 'tasks'
|
||||
| 'ai_sessions'
|
||||
| 'notes_sources'
|
||||
| 'cron'
|
||||
| 'custom_link';
|
||||
|
||||
export type WorkbenchProject = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
initials: string;
|
||||
unreadCount: number;
|
||||
};
|
||||
|
||||
export type WorkbenchChannel = {
|
||||
id: string;
|
||||
projectID: number;
|
||||
type: ChannelType;
|
||||
title: string;
|
||||
icon: string;
|
||||
count?: number;
|
||||
url?: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type InboxMessage = {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
status: 'open' | 'processed' | 'archived';
|
||||
tag: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
export type WorkTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
completed: boolean;
|
||||
owner: string;
|
||||
due: string;
|
||||
tag: string;
|
||||
};
|
||||
|
||||
export type AISessionItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
references: string[];
|
||||
};
|
||||
|
||||
export type NoteSourceItem = {
|
||||
id: string;
|
||||
kind: 'note' | 'file' | 'link';
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
tag: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type CronPlan = {
|
||||
id: string;
|
||||
title: string;
|
||||
schedule: string;
|
||||
nextRun: string;
|
||||
enabled: boolean;
|
||||
lastResult: string;
|
||||
owner: string;
|
||||
};
|
||||
|
||||
export type InspectorItem = {
|
||||
title: string;
|
||||
type: string;
|
||||
description: string;
|
||||
properties: Array<{ label: string; value: string }>;
|
||||
};
|
||||
|
||||
export type ProjectWorkspace = {
|
||||
project: WorkbenchProject;
|
||||
channels: WorkbenchChannel[];
|
||||
tags: string[];
|
||||
recentSessions: AISessionItem[];
|
||||
inbox: InboxMessage[];
|
||||
tasks: WorkTask[];
|
||||
aiSessions: AISessionItem[];
|
||||
notesSources: NoteSourceItem[];
|
||||
cronPlans: CronPlan[];
|
||||
};
|
||||
@@ -1,165 +1,357 @@
|
||||
:root {
|
||||
color: #202124;
|
||||
background: #f6f7f9;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #18181b;
|
||||
background: #f4f4f5;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.5;
|
||||
--background: #f4f4f5;
|
||||
--panel: #ffffff;
|
||||
--panel-muted: #fafafa;
|
||||
--border: #d4d4d8;
|
||||
--text: #18181b;
|
||||
--muted: #71717a;
|
||||
--primary: #0f7ae5;
|
||||
--primary-strong: #0969c8;
|
||||
--success: #15803d;
|
||||
--danger: #b91c1c;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid #d9dde3;
|
||||
background: #ffffff;
|
||||
.login-panel {
|
||||
width: min(420px, 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sidebar h1 {
|
||||
margin: 0 0 24px;
|
||||
font-size: 24px;
|
||||
.server-login,
|
||||
.login-heading,
|
||||
.channel-page,
|
||||
.object-inspector,
|
||||
.channel-sidebar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
.server-login input,
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 9px 10px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.server-login button,
|
||||
.primary-action {
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 6px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
|
||||
.connection-status,
|
||||
.selection-status {
|
||||
margin: 12px 0 0;
|
||||
color: #5f6673;
|
||||
font-size: 13px;
|
||||
.login-note,
|
||||
.form-error,
|
||||
.channel-header p,
|
||||
.channel-page small,
|
||||
.channel-page > p,
|
||||
.object-inspector > p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.server-login {
|
||||
.form-error { color: var(--danger); }
|
||||
|
||||
.workbench-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 64px 1fr;
|
||||
}
|
||||
|
||||
.workspace-topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 160px minmax(240px, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.brand-mark { font-weight: 700; }
|
||||
.search-box { display: grid; gap: 4px; min-width: 0; }
|
||||
.search-box span { color: var(--muted); font-size: 0.75rem; }
|
||||
.topbar-actions { display: flex; gap: 8px; }
|
||||
.topbar-actions button { width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); }
|
||||
|
||||
.workbench-body {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 76px 280px minmax(0, 1fr) 320px;
|
||||
}
|
||||
|
||||
.project-rail,
|
||||
.channel-sidebar,
|
||||
.object-inspector {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.project-rail {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.project-rail button {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--panel-muted);
|
||||
}
|
||||
|
||||
.project-rail button.active,
|
||||
.channel-group button.active {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.project-rail small { color: var(--muted); font-size: 0.6875rem; }
|
||||
|
||||
.channel-sidebar,
|
||||
.object-inspector,
|
||||
.channel-stage {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.channel-sidebar {
|
||||
align-content: start;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.channel-sidebar > header,
|
||||
.channel-sidebar > .channel-group,
|
||||
.channel-sidebar > .recent-sessions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.channel-group,
|
||||
.task-list,
|
||||
.record-list,
|
||||
.message-list,
|
||||
.file-list,
|
||||
.cron-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.server-login label {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.server-login input {
|
||||
.channel-group button,
|
||||
.task-card,
|
||||
.record-row,
|
||||
.cron-row,
|
||||
.message-row,
|
||||
.file-row,
|
||||
.conversation-summary,
|
||||
.metric-card {
|
||||
width: 100%;
|
||||
border: 1px solid #c7ccd4;
|
||||
border-radius: 6px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.server-login button {
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 6px;
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 9px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
.task-card,
|
||||
.record-row,
|
||||
.cron-row,
|
||||
.message-row,
|
||||
.file-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-metric {
|
||||
border: 1px solid #d9dde3;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dashboard-metric span {
|
||||
display: block;
|
||||
color: #5f6673;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dashboard-metric strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.inbox-review {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inbox-review h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
display: grid;
|
||||
grid-template-columns: 20px 1fr;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
border: 1px solid #d9dde3;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.suggestion-body {
|
||||
.task-card.completed {
|
||||
color: var(--muted);
|
||||
background: var(--panel-muted);
|
||||
}
|
||||
|
||||
.task-check { width: 18px; height: 18px; margin: 3px 0 0; accent-color: var(--primary); }
|
||||
.task-status { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 0.75rem; }
|
||||
|
||||
.channel-sidebar header { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
|
||||
.channel-sidebar header p,
|
||||
.channel-sidebar h2,
|
||||
.channel-sidebar h3 { margin: 0; }
|
||||
.channel-sidebar h2 { font-size: 1rem; }
|
||||
.channel-sidebar h3 { color: var(--muted); font-size: 0.8125rem; font-weight: 600; }
|
||||
.channel-sidebar header button { width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
|
||||
.tag-row,
|
||||
.recent-sessions ul { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
|
||||
.tag-row li { border: 1px solid var(--border); border-radius: 999px; padding: 3px 7px; color: var(--muted); font-size: 0.75rem; }
|
||||
.channel-group button { display: grid; grid-template-columns: 18px minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; }
|
||||
.channel-group small { color: var(--muted); }
|
||||
.channel-icon { color: var(--muted); text-align: center; }
|
||||
.recent-sessions { display: grid; gap: 8px; }
|
||||
.recent-sessions li { display: grid; gap: 2px; min-width: 0; padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-muted); }
|
||||
.recent-sessions span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8125rem; }
|
||||
.recent-sessions small { color: var(--muted); font-size: 0.6875rem; }
|
||||
|
||||
.channel-stage { background: var(--background); }
|
||||
.channel-page { align-content: start; }
|
||||
.channel-header { border-bottom: 1px solid var(--border); padding-bottom: 12px; }
|
||||
.channel-header h1,
|
||||
.channel-page h2 { margin: 0; }
|
||||
.channel-header h1 { font-size: 1.25rem; }
|
||||
.channel-page h2 { font-size: 0.9375rem; }
|
||||
.channel-page h2 + p { margin: 4px 0; }
|
||||
.metric-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
|
||||
.metric-card { display: grid; gap: 4px; text-align: left; }
|
||||
.metric-card strong { font-size: 1.5rem; }
|
||||
.metric-card span { color: var(--muted); font-size: 0.8125rem; }
|
||||
.message-row { grid-template-columns: minmax(0, 1fr) auto; align-items: center; }
|
||||
.file-row { grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; }
|
||||
.file-row > span { min-width: 40px; border: 1px solid var(--border); border-radius: 4px; padding: 3px 5px; color: var(--muted); font-size: 0.6875rem; text-align: center; text-transform: uppercase; }
|
||||
.message-row p,
|
||||
.cron-row p,
|
||||
.conversation-summary p { margin: 4px 0; }
|
||||
.message-row button,
|
||||
.file-row button,
|
||||
.task-card > button,
|
||||
.cron-row button,
|
||||
.conversation-summary button,
|
||||
.copy-url-button,
|
||||
.object-inspector button { border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 6px 9px; }
|
||||
.session-layout { display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr); gap: 12px; }
|
||||
.session-list { display: grid; align-content: start; gap: 6px; }
|
||||
.session-list button { display: grid; gap: 3px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); padding: 9px; text-align: left; }
|
||||
.session-list button.active { border-color: var(--primary); background: #eff6ff; }
|
||||
.session-list small { color: var(--muted); }
|
||||
.conversation-summary { align-content: start; }
|
||||
.custom-link { color: var(--primary-strong); font-weight: 600; width: max-content; }
|
||||
.copy-url-button { width: max-content; }
|
||||
.copy-feedback { margin: 0; color: var(--success); font-size: 0.8125rem; }
|
||||
.copy-feedback.copy-error { color: var(--danger); }
|
||||
|
||||
.object-inspector {
|
||||
align-content: start;
|
||||
grid-auto-rows: min-content;
|
||||
}
|
||||
|
||||
.inspector-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-content: start;
|
||||
grid-auto-rows: min-content;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.suggestion-body small {
|
||||
color: #5f6673;
|
||||
.inspector-tabs button[aria-pressed='true'] {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.suggestions button {
|
||||
justify-self: start;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 6px;
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (max-width: 920px) {
|
||||
.workspace-topbar {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
height: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.shell {
|
||||
.search-box { grid-column: 1 / -1; grid-row: 2; }
|
||||
.topbar-actions { grid-column: 2; grid-row: 1; }
|
||||
|
||||
.workbench-shell {
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.workbench-body {
|
||||
grid-template-columns: 1fr;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
.project-rail,
|
||||
.channel-sidebar,
|
||||
.object-inspector {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #d9dde3;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
.project-rail {
|
||||
grid-template-columns: none;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 48px;
|
||||
align-items: center;
|
||||
max-height: 76px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.project-rail button { min-width: 48px; min-height: 48px; }
|
||||
|
||||
.channel-sidebar {
|
||||
align-content: start;
|
||||
max-height: 220px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.channel-group,
|
||||
.tag-row,
|
||||
.recent-sessions ul {
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(132px, max-content);
|
||||
grid-template-columns: none;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.channel-group button { min-width: 160px; }
|
||||
.channel-group {
|
||||
grid-auto-flow: row;
|
||||
grid-auto-columns: auto;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.channel-group button {
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.channel-group button > span:last-of-type {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.channel-group small { display: none; }
|
||||
.recent-sessions li { min-width: 160px; }
|
||||
.channel-stage { max-height: 36vh; overflow: auto; }
|
||||
.object-inspector { max-height: 288px; overflow: auto; }
|
||||
.session-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -3,30 +3,27 @@ package main
|
||||
import (
|
||||
"log"
|
||||
|
||||
"senlinai-agent/backend/internal/auth"
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/db"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/inbox"
|
||||
"senlinai-agent/backend/internal/projects"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
"senlinai-agent/backend/internal/logic/inbox"
|
||||
"senlinai-agent/backend/internal/logic/projects"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
database, err := db.Open(cfg.DatabaseURL)
|
||||
|
||||
err := models.New(cfg.DSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := domain.AutoMigrate(database); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
authService := auth.NewService(database, cfg.AuthSecret)
|
||||
projectHandler := projects.NewHandler(projects.NewService(database))
|
||||
inboxHandler := inbox.NewHandler(inbox.NewService(database, inbox.StaticAnalyzer{}))
|
||||
router := httpx.NewProtectedRouter(cfg, authService.VerifySession, projectHandler, inboxHandler)
|
||||
if err := router.Run(":" + cfg.Port); err != nil {
|
||||
authService := auth.NewService(cfg.AuthSecret)
|
||||
projectHandler := projects.NewHandler(projects.NewService())
|
||||
inboxHandler := inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{}))
|
||||
appRouter := httpx.NewProtectedRouter(cfg, authService.VerifySession, projectHandler, inboxHandler)
|
||||
if err := appRouter.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
7
backend/etc/agent.dev.yaml
Normal file
7
backend/etc/agent.dev.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
env: development
|
||||
port: "8080"
|
||||
dsn: "postgres://postgres:Weidong2023~!@8.137.107.29:19432/agent_dev?sslmode=disable"
|
||||
storage_dir: "./data/files"
|
||||
auth_secret: "development-auth-secret-change-me"
|
||||
system_ai_key: ""
|
||||
ai_key_encryption_secret: "development-ai-key-secret-change-me"
|
||||
@@ -26,7 +26,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
|
||||
@@ -40,6 +40,8 @@ github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbu
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
)
|
||||
|
||||
type SessionService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSessionService(database *gorm.DB) *SessionService {
|
||||
return &SessionService{db: database}
|
||||
}
|
||||
|
||||
func (s *SessionService) Create(projectID uint, userID uint, title string) (*domain.AISession, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil, errors.New("session title is required")
|
||||
}
|
||||
session := &domain.AISession{ProjectID: projectID, CreatedBy: userID, Title: title}
|
||||
return session, s.db.Create(session).Error
|
||||
}
|
||||
@@ -1,32 +1,44 @@
|
||||
package config
|
||||
|
||||
import "os"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Env string
|
||||
Port string
|
||||
DatabaseURL string
|
||||
StorageDir string
|
||||
AuthSecret string
|
||||
SystemAIKey string
|
||||
AIKeyEncryptionSecret string
|
||||
Env string `yaml:"env"`
|
||||
Port string `yaml:"port"`
|
||||
DSN string `yaml:"dsn"`
|
||||
StorageDir string `yaml:"storage_dir"`
|
||||
AuthSecret string `yaml:"auth_secret"`
|
||||
SystemAIKey string `yaml:"system_ai_key"`
|
||||
AIKeyEncryptionSecret string `yaml:"ai_key_encryption_secret"`
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
Env: getenv("APP_ENV", "development"),
|
||||
Port: getenv("PORT", "8080"),
|
||||
DatabaseURL: getenv("DATABASE_URL", "postgres://agent:agent@localhost:5432/agent?sslmode=disable"),
|
||||
StorageDir: getenv("STORAGE_DIR", "./data/files"),
|
||||
AuthSecret: getenv("AUTH_SECRET", "development-auth-secret-change-me"),
|
||||
SystemAIKey: os.Getenv("SYSTEM_AI_KEY"),
|
||||
AIKeyEncryptionSecret: getenv("AI_KEY_ENCRYPTION_SECRET", "development-ai-key-secret-change-me"),
|
||||
cfg, err := LoadFromDir("etc")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getenv(key string, fallback string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
func LoadFromDir(configDir string) (Config, error) {
|
||||
mode := strings.TrimSpace(os.Getenv("SENLIN_APP_MODE"))
|
||||
if mode == "" {
|
||||
mode = "dev"
|
||||
}
|
||||
return fallback
|
||||
path := filepath.Join(configDir, "agent."+strings.ToLower(mode)+".yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
57
backend/internal/config/config_test.go
Normal file
57
backend/internal/config/config_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadFromDirDefaultsToDevYAML(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeConfig(t, configDir, "agent.dev.yaml", "development", "18080", "postgres://dev", "./dev-files", "dev-auth", "dev-system", "dev-ai")
|
||||
t.Setenv("SENLIN_APP_MODE", "")
|
||||
t.Setenv("PORT", "9999")
|
||||
|
||||
cfg, err := LoadFromDir(configDir)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "development", cfg.Env)
|
||||
require.Equal(t, "18080", cfg.Port)
|
||||
require.Equal(t, "postgres://dev", cfg.DSN)
|
||||
require.Equal(t, "./dev-files", cfg.StorageDir)
|
||||
require.Equal(t, "dev-auth", cfg.AuthSecret)
|
||||
require.Equal(t, "dev-system", cfg.SystemAIKey)
|
||||
require.Equal(t, "dev-ai", cfg.AIKeyEncryptionSecret)
|
||||
}
|
||||
|
||||
func TestLoadFromDirUsesSENLINAppMode(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeConfig(t, configDir, "agent.dev.yaml", "development", "18080", "postgres://dev", "./dev-files", "dev-auth", "", "dev-ai")
|
||||
writeConfig(t, configDir, "agent.prod.yaml", "production", "80", "postgres://prod", "/data/files", "prod-auth", "prod-system", "prod-ai")
|
||||
t.Setenv("SENLIN_APP_MODE", "prod")
|
||||
|
||||
cfg, err := LoadFromDir(configDir)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "production", cfg.Env)
|
||||
require.Equal(t, "80", cfg.Port)
|
||||
require.Equal(t, "postgres://prod", cfg.DSN)
|
||||
require.Equal(t, "/data/files", cfg.StorageDir)
|
||||
require.Equal(t, "prod-auth", cfg.AuthSecret)
|
||||
require.Equal(t, "prod-system", cfg.SystemAIKey)
|
||||
require.Equal(t, "prod-ai", cfg.AIKeyEncryptionSecret)
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, dir string, name string, env string, port string, databaseURL string, storageDir string, authSecret string, systemAIKey string, aiKeySecret string) {
|
||||
t.Helper()
|
||||
content := []byte("env: " + env + "\n" +
|
||||
"port: \"" + port + "\"\n" +
|
||||
"dsn: \"" + databaseURL + "\"\n" +
|
||||
"storage_dir: \"" + storageDir + "\"\n" +
|
||||
"auth_secret: \"" + authSecret + "\"\n" +
|
||||
"system_ai_key: \"" + systemAIKey + "\"\n" +
|
||||
"ai_key_encryption_secret: \"" + aiKeySecret + "\"\n")
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), content, 0o600))
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Open(databaseURL string) (*gorm.DB, error) {
|
||||
return gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
DisplayName string `gorm:"not null"`
|
||||
PasswordHash string `gorm:"not null"`
|
||||
Role string `gorm:"not null;default:user"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
OwnerID uint `gorm:"index;not null"`
|
||||
Name string `gorm:"not null"`
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type InboxItem struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
SourceType string `gorm:"not null"`
|
||||
Title string
|
||||
Body string
|
||||
Status string `gorm:"not null;default:open"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
AssigneeID *uint `gorm:"index"`
|
||||
SourceInboxItemID *uint `gorm:"index"`
|
||||
Title string `gorm:"not null"`
|
||||
Description string
|
||||
Status string `gorm:"not null;default:open"`
|
||||
SortOrder int `gorm:"not null;default:0"`
|
||||
DueAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Note struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
SourceInboxItemID *uint `gorm:"index"`
|
||||
Title string `gorm:"not null"`
|
||||
Markdown string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
SourceInboxItemID *uint `gorm:"index"`
|
||||
Kind string `gorm:"not null"`
|
||||
Title string `gorm:"not null"`
|
||||
URL string
|
||||
FilePath string
|
||||
ContentText string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AISession struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
Title string `gorm:"not null"`
|
||||
Context string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
Name string `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ProjectEvent struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
ActorID uint `gorm:"index;not null"`
|
||||
EventType string `gorm:"not null"`
|
||||
EntityType string `gorm:"not null"`
|
||||
EntityID uint `gorm:"not null"`
|
||||
Summary string `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AIKey struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"uniqueIndex;not null"`
|
||||
Provider string `gorm:"not null"`
|
||||
EncryptedAPIKey string `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AICallLog struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
UserID uint `gorm:"index;not null"`
|
||||
Provider string `gorm:"not null"`
|
||||
UsedKeyType string `gorm:"not null"`
|
||||
Action string `gorm:"not null"`
|
||||
Status string `gorm:"not null"`
|
||||
Error string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type TaskShare struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
TaskID uint `gorm:"index;not null"`
|
||||
ObjectType string `gorm:"not null"`
|
||||
ObjectID uint `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func AutoMigrate(database *gorm.DB) error {
|
||||
return database.AutoMigrate(
|
||||
&User{}, &Project{}, &InboxItem{}, &Task{}, &Note{}, &Source{},
|
||||
&AISession{}, &Tag{}, &ProjectEvent{}, &AIKey{}, &AICallLog{}, &TaskShare{},
|
||||
)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAutoMigrateCreatesCoreTables(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, AutoMigrate(database))
|
||||
|
||||
for _, table := range []string{
|
||||
"users", "projects", "inbox_items", "tasks", "notes", "sources",
|
||||
"ai_sessions", "tags", "project_events", "ai_keys", "ai_call_logs", "task_shares",
|
||||
} {
|
||||
require.True(t, database.Migrator().HasTable(table), "missing table %s", table)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"senlinai-agent/backend/internal/auth"
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
)
|
||||
|
||||
type RouteRegistrar interface {
|
||||
@@ -27,6 +27,7 @@ func newRouter(cfg config.Config, tokenVerifier func(string) (uint, error), regi
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(cors())
|
||||
|
||||
router.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
@@ -41,3 +42,29 @@ func newRouter(cfg config.Config, tokenVerifier func(string) (uint, error), regi
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func cors() gin.HandlerFunc {
|
||||
allowedOrigins := map[string]bool{
|
||||
"http://localhost:5173": true,
|
||||
"http://127.0.0.1:5173": true,
|
||||
"http://tauri.localhost": true,
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if allowedOrigins[origin] {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Vary", "Origin")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
}
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,36 @@ func TestNewRouterRegistersFeatureRoutesUnderAPI(t *testing.T) {
|
||||
require.JSONEq(t, `{"pong":true}`, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestRouterAddsCORSHeadersForLocalWebClient(t *testing.T) {
|
||||
router := NewRouter(config.Config{Env: "test"}, testRegistrar{})
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||||
req.Header.Set("Origin", "http://localhost:5173")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "http://localhost:5173", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
require.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "Authorization")
|
||||
}
|
||||
|
||||
func TestRouterHandlesCORSPreflightBeforeAuth(t *testing.T) {
|
||||
router := NewProtectedRouter(config.Config{Env: "test"}, func(token string) (uint, error) {
|
||||
return 0, http.ErrNoCookie
|
||||
}, testRegistrar{})
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/ping", nil)
|
||||
req.Header.Set("Origin", "http://localhost:5173")
|
||||
req.Header.Set("Access-Control-Request-Method", "GET")
|
||||
req.Header.Set("Access-Control-Request-Headers", "Authorization")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, rec.Code)
|
||||
require.Equal(t, "http://localhost:5173", rec.Header().Get("Access-Control-Allow-Origin"))
|
||||
require.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "Authorization")
|
||||
}
|
||||
|
||||
type testRegistrar struct{}
|
||||
|
||||
func (testRegistrar) Register(router gin.IRouter) {
|
||||
|
||||
@@ -10,13 +10,11 @@ import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Gateway struct {
|
||||
db *gorm.DB
|
||||
systemKey string
|
||||
encryptionSecret string
|
||||
}
|
||||
@@ -27,12 +25,12 @@ type SelectedKey struct {
|
||||
KeyType string
|
||||
}
|
||||
|
||||
func NewGateway(database *gorm.DB, systemKey string) *Gateway {
|
||||
return NewGatewayWithSecret(database, systemKey, "development-ai-key-secret-change-me")
|
||||
func NewGateway(systemKey string) *Gateway {
|
||||
return NewGatewayWithSecret(systemKey, "development-ai-key-secret-change-me")
|
||||
}
|
||||
|
||||
func NewGatewayWithSecret(database *gorm.DB, systemKey string, encryptionSecret string) *Gateway {
|
||||
return &Gateway{db: database, systemKey: systemKey, encryptionSecret: encryptionSecret}
|
||||
func NewGatewayWithSecret(systemKey string, encryptionSecret string) *Gateway {
|
||||
return &Gateway{systemKey: systemKey, encryptionSecret: encryptionSecret}
|
||||
}
|
||||
|
||||
func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error {
|
||||
@@ -40,16 +38,16 @@ func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := domain.AIKey{UserID: userID, Provider: provider, EncryptedAPIKey: encrypted}
|
||||
return g.db.Clauses(clause.OnConflict{
|
||||
key := models.SenlinAgentAIKey{UserID: userID, Provider: provider, EncryptedAPIKey: encrypted}
|
||||
return models.DBService.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"provider", "encrypted_api_key", "updated_at"}),
|
||||
}).Create(&key).Error
|
||||
}
|
||||
|
||||
func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) {
|
||||
var userKey domain.AIKey
|
||||
if err := g.db.Where("user_id = ?", userID).First(&userKey).Error; err == nil {
|
||||
var userKey models.SenlinAgentAIKey
|
||||
if err := models.DBService.Where("user_id = ?", userID).First(&userKey).Error; err == nil {
|
||||
apiKey, err := decryptAPIKey(userKey.EncryptedAPIKey, g.encryptionSecret)
|
||||
if err != nil {
|
||||
return SelectedKey{}, err
|
||||
@@ -63,7 +61,7 @@ func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) {
|
||||
}
|
||||
|
||||
func (g *Gateway) RecordCall(userID uint, provider string, usedKeyType string, action string, status string, errText string) error {
|
||||
return g.db.Create(&domain.AICallLog{
|
||||
return models.DBService.Create(&models.SenlinAgentAICallLog{
|
||||
UserID: userID,
|
||||
Provider: provider,
|
||||
UsedKeyType: usedKeyType,
|
||||
@@ -78,7 +76,7 @@ func (g *Gateway) CheckRateLimit(userID uint, action string, limit int, window t
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := g.db.Model(&domain.AICallLog{}).
|
||||
if err := models.DBService.Model(&models.SenlinAgentAICallLog{}).
|
||||
Where("user_id = ? AND action = ? AND created_at >= ?", userID, action, time.Now().Add(-window)).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestSelectKeyPrefersUserKey(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
require.NoError(t, database.Create(&domain.AIKey{UserID: 3, Provider: "openai", EncryptedAPIKey: "user-key"}).Error)
|
||||
gateway := NewGateway(database, "system-key")
|
||||
require.NoError(t, database.Create(&models.SenlinAgentAIKey{UserID: 3, Provider: "openai", EncryptedAPIKey: "user-key"}).Error)
|
||||
gateway := NewGateway("system-key")
|
||||
|
||||
selected, err := gateway.SelectKey(3)
|
||||
|
||||
@@ -25,11 +25,11 @@ func TestSelectKeyPrefersUserKey(t *testing.T) {
|
||||
|
||||
func TestSaveUserKeyEncryptsStoredKey(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
gateway := NewGatewayWithSecret(database, "system-key", "test-encryption-secret")
|
||||
gateway := NewGatewayWithSecret("system-key", "test-encryption-secret")
|
||||
|
||||
require.NoError(t, gateway.SaveUserKey(3, "openai", "user-key"))
|
||||
|
||||
var stored domain.AIKey
|
||||
var stored models.SenlinAgentAIKey
|
||||
require.NoError(t, database.Where("user_id = ?", 3).First(&stored).Error)
|
||||
require.NotEqual(t, "user-key", stored.EncryptedAPIKey)
|
||||
require.Contains(t, stored.EncryptedAPIKey, "v1:")
|
||||
@@ -39,8 +39,8 @@ func TestSaveUserKeyEncryptsStoredKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSelectKeyFallsBackToSystemKey(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
gateway := NewGateway(database, "system-key")
|
||||
newTestDB(t)
|
||||
gateway := NewGateway("system-key")
|
||||
|
||||
selected, err := gateway.SelectKey(3)
|
||||
|
||||
@@ -50,8 +50,8 @@ func TestSelectKeyFallsBackToSystemKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSelectKeyReturnsErrorWhenNoKeyAvailable(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
gateway := NewGateway(database, "")
|
||||
newTestDB(t)
|
||||
gateway := NewGateway("")
|
||||
|
||||
_, err := gateway.SelectKey(3)
|
||||
|
||||
@@ -60,11 +60,11 @@ func TestSelectKeyReturnsErrorWhenNoKeyAvailable(t *testing.T) {
|
||||
|
||||
func TestRecordCallStoresAuditFields(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
gateway := NewGateway(database, "system-key")
|
||||
gateway := NewGateway("system-key")
|
||||
|
||||
require.NoError(t, gateway.RecordCall(3, "openai", "system", "inbox_analyze", "failed", "rate limited"))
|
||||
|
||||
var log domain.AICallLog
|
||||
var log models.SenlinAgentAICallLog
|
||||
require.NoError(t, database.First(&log).Error)
|
||||
require.Equal(t, uint(3), log.UserID)
|
||||
require.Equal(t, "openai", log.Provider)
|
||||
@@ -75,8 +75,8 @@ func TestRecordCallStoresAuditFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
gateway := NewGateway(database, "system-key")
|
||||
newTestDB(t)
|
||||
gateway := NewGateway("system-key")
|
||||
require.NoError(t, gateway.RecordCall(3, "openai", "system", "inbox_analyze", "succeeded", ""))
|
||||
|
||||
err := gateway.CheckRateLimit(3, "inbox_analyze", 1, time.Hour)
|
||||
@@ -85,8 +85,8 @@ func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateAISession(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewSessionService(database)
|
||||
newTestDB(t)
|
||||
service := NewSessionService()
|
||||
|
||||
session, err := service.Create(7, 3, "报价分析")
|
||||
|
||||
@@ -100,6 +100,7 @@ func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, domain.AutoMigrate(database))
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
24
backend/internal/logic/ai/sessions.go
Normal file
24
backend/internal/logic/ai/sessions.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type SessionService struct {
|
||||
}
|
||||
|
||||
func NewSessionService() *SessionService {
|
||||
return &SessionService{}
|
||||
}
|
||||
|
||||
func (s *SessionService) Create(projectID uint, userID uint, title string) (*models.SenlinAgentAISession, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil, errors.New("session title is required")
|
||||
}
|
||||
session := &models.SenlinAgentAISession{ProjectID: projectID, CreatedBy: userID, Title: title}
|
||||
return session, models.DBService.Create(session).Error
|
||||
}
|
||||
@@ -12,19 +12,17 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
secret string
|
||||
inviteTokenTTL time.Duration
|
||||
sessionTTL time.Duration
|
||||
}
|
||||
|
||||
func NewService(database *gorm.DB, secret string) *Service {
|
||||
return &Service{db: database, secret: secret, inviteTokenTTL: 7 * 24 * time.Hour, sessionTTL: 24 * time.Hour}
|
||||
func NewService(secret string) *Service {
|
||||
return &Service{secret: secret, inviteTokenTTL: 7 * 24 * time.Hour, sessionTTL: 24 * time.Hour}
|
||||
}
|
||||
|
||||
func (s *Service) CreateInvite(adminID uint, email string) (string, error) {
|
||||
@@ -40,7 +38,7 @@ func (s *Service) CreateInvite(adminID uint, email string) (string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) RegisterWithInvite(token, email, displayName, password string) (*domain.User, error) {
|
||||
func (s *Service) RegisterWithInvite(token, email, displayName, password string) (*models.SenlinAgentUser, error) {
|
||||
payload, err := s.verifyToken(token, "invite")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,18 +50,18 @@ func (s *Service) RegisterWithInvite(token, email, displayName, password string)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := &domain.User{
|
||||
user := &models.SenlinAgentUser{
|
||||
Email: payload.Subject,
|
||||
DisplayName: strings.TrimSpace(displayName),
|
||||
PasswordHash: string(hash),
|
||||
Role: "user",
|
||||
}
|
||||
return user, s.db.Create(user).Error
|
||||
return user, models.DBService.Create(user).Error
|
||||
}
|
||||
|
||||
func (s *Service) Login(email, password string) (string, error) {
|
||||
var user domain.User
|
||||
if err := s.db.Where("email = ?", strings.ToLower(strings.TrimSpace(email))).First(&user).Error; err != nil {
|
||||
var user models.SenlinAgentUser
|
||||
if err := models.DBService.Where("email = ?", strings.ToLower(strings.TrimSpace(email))).First(&user).Error; err != nil {
|
||||
return "", errors.New("invalid credentials")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestRegisterWithInviteCreatesUser(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database, "test-secret")
|
||||
newTestDB(t)
|
||||
service := NewService("test-secret")
|
||||
token, err := service.CreateInvite(1, "lead@example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -25,8 +25,8 @@ func TestRegisterWithInviteCreatesUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRegisterWithInviteRejectsWrongEmail(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database, "test-secret")
|
||||
newTestDB(t)
|
||||
service := NewService("test-secret")
|
||||
token, err := service.CreateInvite(1, "lead@example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -36,8 +36,8 @@ func TestRegisterWithInviteRejectsWrongEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoginRejectsInvalidPassword(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database, "test-secret")
|
||||
newTestDB(t)
|
||||
service := NewService("test-secret")
|
||||
token, err := service.CreateInvite(1, "lead@example.com")
|
||||
require.NoError(t, err)
|
||||
_, err = service.RegisterWithInvite(token, "lead@example.com", "Lead", "password123")
|
||||
@@ -49,8 +49,8 @@ func TestLoginRejectsInvalidPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoginReturnsSignedSessionToken(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database, "test-secret")
|
||||
newTestDB(t)
|
||||
service := NewService("test-secret")
|
||||
token, err := service.CreateInvite(1, "lead@example.com")
|
||||
require.NoError(t, err)
|
||||
user, err := service.RegisterWithInvite(token, "lead@example.com", "Lead", "password123")
|
||||
@@ -66,8 +66,8 @@ func TestLoginReturnsSignedSessionToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestVerifySessionRejectsForgeableLegacyToken(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database, "test-secret")
|
||||
newTestDB(t)
|
||||
service := NewService("test-secret")
|
||||
|
||||
_, err := service.VerifySession("user:1")
|
||||
|
||||
@@ -78,6 +78,7 @@ func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, domain.AutoMigrate(database))
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"senlinai-agent/backend/internal/auth"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type CaptureInput struct {
|
||||
@@ -22,34 +22,33 @@ type Suggestion struct {
|
||||
}
|
||||
|
||||
type Analyzer interface {
|
||||
Analyze(item domain.InboxItem, userID uint) ([]Suggestion, error)
|
||||
Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error)
|
||||
}
|
||||
|
||||
type StaticAnalyzer struct {
|
||||
Suggestions []Suggestion
|
||||
}
|
||||
|
||||
func (a StaticAnalyzer) Analyze(item domain.InboxItem, userID uint) ([]Suggestion, error) {
|
||||
func (a StaticAnalyzer) Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error) {
|
||||
return a.Suggestions, nil
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
analyzer Analyzer
|
||||
}
|
||||
|
||||
func NewService(database *gorm.DB, analyzer Analyzer) *Service {
|
||||
return &Service{db: database, analyzer: analyzer}
|
||||
func NewService(analyzer Analyzer) *Service {
|
||||
return &Service{analyzer: analyzer}
|
||||
}
|
||||
|
||||
func (s *Service) Capture(input CaptureInput) (*domain.InboxItem, error) {
|
||||
func (s *Service) Capture(input CaptureInput) (*models.SenlinAgentInboxItem, error) {
|
||||
if input.ProjectID == 0 || input.UserID == 0 {
|
||||
return nil, errors.New("project and user are required")
|
||||
}
|
||||
if input.SourceType == "" {
|
||||
return nil, errors.New("source type is required")
|
||||
}
|
||||
item := &domain.InboxItem{
|
||||
item := &models.SenlinAgentInboxItem{
|
||||
ProjectID: input.ProjectID,
|
||||
CreatedBy: input.UserID,
|
||||
SourceType: input.SourceType,
|
||||
@@ -57,12 +56,12 @@ func (s *Service) Capture(input CaptureInput) (*domain.InboxItem, error) {
|
||||
Body: input.Body,
|
||||
Status: "open",
|
||||
}
|
||||
return item, s.db.Create(item).Error
|
||||
return item, models.DBService.Create(item).Error
|
||||
}
|
||||
|
||||
func (s *Service) Analyze(itemID uint, userID uint) ([]Suggestion, error) {
|
||||
var item domain.InboxItem
|
||||
if err := s.db.First(&item, itemID).Error; err != nil {
|
||||
var item models.SenlinAgentInboxItem
|
||||
if err := models.DBService.First(&item, itemID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.analyzer == nil {
|
||||
@@ -72,8 +71,8 @@ func (s *Service) Analyze(itemID uint, userID uint) ([]Suggestion, error) {
|
||||
}
|
||||
|
||||
func (s *Service) Confirm(itemID uint, selected []Suggestion) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var item domain.InboxItem
|
||||
return models.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var item models.SenlinAgentInboxItem
|
||||
if err := tx.First(&item, itemID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -81,15 +80,15 @@ func (s *Service) Confirm(itemID uint, selected []Suggestion) error {
|
||||
for _, suggestion := range selected {
|
||||
switch suggestion.Kind {
|
||||
case "task":
|
||||
if err := tx.Create(&domain.Task{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil {
|
||||
if err := tx.Create(&models.SenlinAgentTask{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case "note":
|
||||
if err := tx.Create(&domain.Note{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil {
|
||||
if err := tx.Create(&models.SenlinAgentNote{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
case "source":
|
||||
if err := tx.Create(&domain.Source{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil {
|
||||
if err := tx.Create(&models.SenlinAgentSource{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestAnalyzeReturnsSuggestionsWithoutCreatingObjects(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database, StaticAnalyzer{
|
||||
service := NewService(StaticAnalyzer{
|
||||
Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}},
|
||||
})
|
||||
item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"})
|
||||
@@ -23,13 +23,13 @@ func TestAnalyzeReturnsSuggestionsWithoutCreatingObjects(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, suggestions, 1)
|
||||
var count int64
|
||||
require.NoError(t, database.Model(&domain.Task{}).Count(&count).Error)
|
||||
require.NoError(t, database.Model(&models.SenlinAgentTask{}).Count(&count).Error)
|
||||
require.Equal(t, int64(0), count)
|
||||
}
|
||||
|
||||
func TestConfirmCreatesSelectedObjectsAndKeepsInboxItem(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database, StaticAnalyzer{
|
||||
service := NewService(StaticAnalyzer{
|
||||
Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}},
|
||||
})
|
||||
item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"})
|
||||
@@ -40,13 +40,13 @@ func TestConfirmCreatesSelectedObjectsAndKeepsInboxItem(t *testing.T) {
|
||||
err = service.Confirm(item.ID, suggestions)
|
||||
|
||||
require.NoError(t, err)
|
||||
var tasks []domain.Task
|
||||
var tasks []models.SenlinAgentTask
|
||||
require.NoError(t, database.Find(&tasks).Error)
|
||||
require.Len(t, tasks, 1)
|
||||
require.Equal(t, "跟进报价", tasks[0].Title)
|
||||
require.NotNil(t, tasks[0].SourceInboxItemID)
|
||||
require.Equal(t, item.ID, *tasks[0].SourceInboxItemID)
|
||||
var reloaded domain.InboxItem
|
||||
var reloaded models.SenlinAgentInboxItem
|
||||
require.NoError(t, database.First(&reloaded, item.ID).Error)
|
||||
require.Equal(t, "processed", reloaded.Status)
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, domain.AutoMigrate(database))
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
24
backend/internal/logic/notes/service.go
Normal file
24
backend/internal/logic/notes/service.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) CreateNote(projectID uint, userID uint, title string, markdown string) (*models.SenlinAgentNote, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil, errors.New("note title is required")
|
||||
}
|
||||
note := &models.SenlinAgentNote{ProjectID: projectID, CreatedBy: userID, Title: title, Markdown: markdown}
|
||||
return note, models.DBService.Create(note).Error
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"senlinai-agent/backend/internal/auth"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
74
backend/internal/logic/projects/service.go
Normal file
74
backend/internal/logic/projects/service.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
}
|
||||
|
||||
type Dashboard struct {
|
||||
ProjectID uint `json:"project_id"`
|
||||
PendingInboxCount int64 `json:"pending_inbox_count"`
|
||||
OpenTaskCount int64 `json:"open_task_count"`
|
||||
RecentNoteCount int64 `json:"recent_note_count"`
|
||||
RecentSessionCount int64 `json:"recent_session_count"`
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) CreateProject(ownerID uint, name string, description string) (*models.SenlinAgentProject, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("project name is required")
|
||||
}
|
||||
project := &models.SenlinAgentProject{OwnerID: ownerID, Name: name, Description: description}
|
||||
return project, models.DBService.Create(project).Error
|
||||
}
|
||||
|
||||
func (s *Service) ListProjects(ownerID uint) ([]models.SenlinAgentProject, error) {
|
||||
var projects []models.SenlinAgentProject
|
||||
err := models.DBService.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error
|
||||
return projects, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateTag(projectID uint, name string) (*models.SenlinAgentTag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("tag name is required")
|
||||
}
|
||||
tag := &models.SenlinAgentTag{ProjectID: projectID, Name: name}
|
||||
return tag, models.DBService.Create(tag).Error
|
||||
}
|
||||
|
||||
func (s *Service) ListTags(projectID uint) ([]models.SenlinAgentTag, error) {
|
||||
var tags []models.SenlinAgentTag
|
||||
err := models.DBService.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
|
||||
var project models.SenlinAgentProject
|
||||
if err := models.DBService.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
dashboard := Dashboard{ProjectID: projectID}
|
||||
if err := models.DBService.Model(&models.SenlinAgentInboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentTask{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&dashboard.OpenTaskCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentNote{}).Where("project_id = ?", projectID).Count(&dashboard.RecentNoteCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := models.DBService.Model(&models.SenlinAgentAISession{}).Where("project_id = ?", projectID).Count(&dashboard.RecentSessionCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
return dashboard, nil
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import (
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestProjectTagsAreScopedToProject(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database)
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
first, err := service.CreateProject(1, "Alpha", "")
|
||||
require.NoError(t, err)
|
||||
second, err := service.CreateProject(1, "Beta", "")
|
||||
@@ -31,15 +31,15 @@ func TestProjectTagsAreScopedToProject(t *testing.T) {
|
||||
|
||||
func TestDashboardCountsOnlyRequestedProject(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database)
|
||||
service := NewService()
|
||||
first, err := service.CreateProject(1, "Alpha", "")
|
||||
require.NoError(t, err)
|
||||
second, err := service.CreateProject(1, "Beta", "")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, database.Create(&domain.InboxItem{ProjectID: first.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error)
|
||||
require.NoError(t, database.Create(&domain.InboxItem{ProjectID: second.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error)
|
||||
require.NoError(t, database.Create(&domain.Task{ProjectID: first.ID, CreatedBy: 1, Title: "A", Status: "open"}).Error)
|
||||
require.NoError(t, database.Create(&domain.Task{ProjectID: second.ID, CreatedBy: 1, Title: "B", Status: "open"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: first.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: second.ID, CreatedBy: 1, SourceType: "text", Status: "open"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: first.ID, CreatedBy: 1, Title: "A", Status: "open"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: second.ID, CreatedBy: 1, Title: "B", Status: "open"}).Error)
|
||||
|
||||
dashboard, err := service.Dashboard(1, first.ID)
|
||||
|
||||
@@ -49,8 +49,8 @@ func TestDashboardCountsOnlyRequestedProject(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDashboardRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database)
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
project, err := service.CreateProject(2, "Beta", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -63,6 +63,7 @@ func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, domain.AutoMigrate(database))
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
135
backend/internal/logic/search/service.go
Normal file
135
backend/internal/logic/search/service.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Type string `json:"type"`
|
||||
ID uint `json:"id"`
|
||||
ProjectID uint `json:"project_id"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) Search(userID uint, query string) ([]Result, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return []Result{}, nil
|
||||
}
|
||||
if models.DBService.Dialector.Name() == "postgres" {
|
||||
return s.searchPostgres(userID, query)
|
||||
}
|
||||
|
||||
like := "%" + query + "%"
|
||||
results := []Result{}
|
||||
var projects []models.SenlinAgentProject
|
||||
if err := models.DBService.Where("owner_id = ? AND (name LIKE ? OR description LIKE ?)", userID, like, like).Find(&projects).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, project := range projects {
|
||||
results = append(results, Result{Type: "project", ID: project.ID, ProjectID: project.ID, Title: project.Name, Snippet: project.Description})
|
||||
}
|
||||
|
||||
var tasks []models.SenlinAgentTask
|
||||
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_tasks.project_id").
|
||||
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_tasks.title LIKE ? OR senlin_agent_tasks.description LIKE ?)", userID, like, like).
|
||||
Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
results = append(results, Result{Type: "task", ID: task.ID, ProjectID: task.ProjectID, Title: task.Title, Snippet: task.Description})
|
||||
}
|
||||
|
||||
var notes []models.SenlinAgentNote
|
||||
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_notes.project_id").
|
||||
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_notes.title LIKE ? OR senlin_agent_notes.markdown LIKE ?)", userID, like, like).
|
||||
Find(¬es).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, note := range notes {
|
||||
results = append(results, Result{Type: "note", ID: note.ID, ProjectID: note.ProjectID, Title: note.Title, Snippet: note.Markdown})
|
||||
}
|
||||
|
||||
var sources []models.SenlinAgentSource
|
||||
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_sources.project_id").
|
||||
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_sources.title LIKE ? OR senlin_agent_sources.url LIKE ? OR senlin_agent_sources.content_text LIKE ?)", userID, like, like, like).
|
||||
Find(&sources).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, source := range sources {
|
||||
results = append(results, Result{Type: "source", ID: source.ID, ProjectID: source.ProjectID, Title: source.Title, Snippet: source.ContentText})
|
||||
}
|
||||
|
||||
var inboxItems []models.SenlinAgentInboxItem
|
||||
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_inbox_items.project_id").
|
||||
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_inbox_items.title LIKE ? OR senlin_agent_inbox_items.body LIKE ?)", userID, like, like).
|
||||
Find(&inboxItems).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range inboxItems {
|
||||
results = append(results, Result{Type: "inbox", ID: item.ID, ProjectID: item.ProjectID, Title: item.Title, Snippet: item.Body})
|
||||
}
|
||||
|
||||
var sessions []models.SenlinAgentAISession
|
||||
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_ai_sessions.project_id").
|
||||
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_ai_sessions.title LIKE ? OR senlin_agent_ai_sessions.context LIKE ?)", userID, like, like).
|
||||
Find(&sessions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
results = append(results, Result{Type: "ai_session", ID: session.ID, ProjectID: session.ProjectID, Title: session.Title, Snippet: session.Context})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) searchPostgres(userID uint, query string) ([]Result, error) {
|
||||
var results []Result
|
||||
err := models.DBService.Raw(`
|
||||
SELECT 'project' AS type, p.id, p.id AS project_id, p.name AS title, p.description AS snippet
|
||||
FROM senlin_agent_projects p
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(p.name, '') || ' ' || coalesce(p.description, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'task' AS type, t.id, t.project_id, t.title, t.description AS snippet
|
||||
FROM senlin_agent_tasks t
|
||||
JOIN senlin_agent_projects p ON p.id = t.project_id
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(t.title, '') || ' ' || coalesce(t.description, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'note' AS type, n.id, n.project_id, n.title, n.markdown AS snippet
|
||||
FROM senlin_agent_notes n
|
||||
JOIN senlin_agent_projects p ON p.id = n.project_id
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(n.title, '') || ' ' || coalesce(n.markdown, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'source' AS type, s.id, s.project_id, s.title, s.content_text AS snippet
|
||||
FROM senlin_agent_sources s
|
||||
JOIN senlin_agent_projects p ON p.id = s.project_id
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(s.title, '') || ' ' || coalesce(s.url, '') || ' ' || coalesce(s.content_text, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'inbox' AS type, i.id, i.project_id, i.title, i.body AS snippet
|
||||
FROM senlin_agent_inbox_items i
|
||||
JOIN senlin_agent_projects p ON p.id = i.project_id
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(i.title, '') || ' ' || coalesce(i.body, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'ai_session' AS type, a.id, a.project_id, a.title, a.context AS snippet
|
||||
FROM senlin_agent_ai_sessions a
|
||||
JOIN senlin_agent_projects p ON p.id = a.project_id
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(a.title, '') || ' ' || coalesce(a.context, '')) @@ plainto_tsquery('simple', ?)
|
||||
LIMIT 50
|
||||
`, userID, query, userID, query, userID, query, userID, query, userID, query, userID, query).Scan(&results).Error
|
||||
return results, err
|
||||
}
|
||||
52
backend/internal/logic/search/service_test.go
Normal file
52
backend/internal/logic/search/service_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestSearchFindsNoteBody(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 1, OwnerID: 7, Name: "支付项目"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentNote{ProjectID: 1, CreatedBy: 7, Title: "接口方案", Markdown: "二维码支付回调设计"}).Error)
|
||||
|
||||
service := NewService()
|
||||
results, err := service.Search(7, "回调")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "note", results[0].Type)
|
||||
}
|
||||
|
||||
func TestSearchFindsCoreProjectObjects(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 1, OwnerID: 7, Name: "支付项目"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: 1, CreatedBy: 7, Title: "回调任务", Description: "检查 webhook"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentSource{ProjectID: 1, CreatedBy: 7, Kind: "link", Title: "支付文档", URL: "https://example.com/pay", ContentText: "webhook 签名"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: 1, CreatedBy: 7, SourceType: "text", Title: "收集项", Body: "webhook 待整理"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentAISession{ProjectID: 1, CreatedBy: 7, Title: "AI 分析", Context: "webhook 问答"}).Error)
|
||||
|
||||
service := NewService()
|
||||
results, err := service.Search(7, "webhook")
|
||||
|
||||
require.NoError(t, err)
|
||||
types := make(map[string]bool)
|
||||
for _, result := range results {
|
||||
types[result.Type] = true
|
||||
}
|
||||
require.True(t, types["task"])
|
||||
require.True(t, types["source"])
|
||||
require.True(t, types["inbox"])
|
||||
require.True(t, types["ai_session"])
|
||||
}
|
||||
@@ -5,11 +5,10 @@ import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type LinkedObject struct {
|
||||
@@ -17,20 +16,20 @@ type LinkedObject struct {
|
||||
ObjectID uint `json:"object_id"`
|
||||
}
|
||||
|
||||
func NewService(database *gorm.DB) *Service {
|
||||
return &Service{db: database}
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) Assign(taskID uint, assigneeID uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var task domain.Task
|
||||
return models.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var task models.SenlinAgentTask
|
||||
if err := tx.First(&task, taskID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&task).Update("assignee_id", assigneeID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&domain.ProjectEvent{
|
||||
return tx.Create(&models.SenlinAgentProjectEvent{
|
||||
ProjectID: task.ProjectID,
|
||||
ActorID: task.CreatedBy,
|
||||
EventType: "task_assigned",
|
||||
@@ -45,18 +44,18 @@ func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) err
|
||||
if objectType != "note" && objectType != "source" {
|
||||
return errors.New("unsupported shared object type")
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var task domain.Task
|
||||
return models.DBService.Transaction(func(tx *gorm.DB) error {
|
||||
var task models.SenlinAgentTask
|
||||
if err := tx.First(&task, taskID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureSharedObjectInProject(tx, task.ProjectID, objectType, objectID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&domain.TaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil {
|
||||
if err := tx.Create(&models.SenlinAgentTaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&domain.ProjectEvent{
|
||||
return tx.Create(&models.SenlinAgentProjectEvent{
|
||||
ProjectID: task.ProjectID,
|
||||
ActorID: task.CreatedBy,
|
||||
EventType: "task_object_shared",
|
||||
@@ -68,15 +67,15 @@ func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) err
|
||||
}
|
||||
|
||||
func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObject, error) {
|
||||
var task domain.Task
|
||||
if err := s.db.First(&task, taskID).Error; err != nil {
|
||||
var task models.SenlinAgentTask
|
||||
if err := models.DBService.First(&task, taskID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task.AssigneeID == nil || *task.AssigneeID != viewerID {
|
||||
return []LinkedObject{}, nil
|
||||
}
|
||||
var shares []domain.TaskShare
|
||||
if err := s.db.Where("task_id = ?", taskID).Find(&shares).Error; err != nil {
|
||||
var shares []models.SenlinAgentTaskShare
|
||||
if err := models.DBService.Where("task_id = ?", taskID).Find(&shares).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects := make([]LinkedObject, 0, len(shares))
|
||||
@@ -90,7 +89,7 @@ func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string,
|
||||
switch objectType {
|
||||
case "note":
|
||||
var count int64
|
||||
if err := tx.Model(&domain.Note{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
|
||||
if err := tx.Model(&models.SenlinAgentNote{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
@@ -98,7 +97,7 @@ func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string,
|
||||
}
|
||||
case "source":
|
||||
var count int64
|
||||
if err := tx.Model(&domain.Source{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
|
||||
if err := tx.Model(&models.SenlinAgentSource{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
@@ -7,17 +7,17 @@ import (
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestAssigneeOnlySeesExplicitlySharedObjects(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
assigneeID := uint(2)
|
||||
task := domain.Task{ProjectID: 1, CreatedBy: 1, AssigneeID: &assigneeID, Title: "处理合同"}
|
||||
note := domain.Note{ProjectID: 1, CreatedBy: 1, Title: "合同背景", Markdown: "只在共享后可见"}
|
||||
task := models.SenlinAgentTask{ProjectID: 1, CreatedBy: 1, AssigneeID: &assigneeID, Title: "处理合同"}
|
||||
note := models.SenlinAgentNote{ProjectID: 1, CreatedBy: 1, Title: "合同背景", Markdown: "只在共享后可见"}
|
||||
require.NoError(t, database.Create(&task).Error)
|
||||
require.NoError(t, database.Create(¬e).Error)
|
||||
service := NewService(database)
|
||||
service := NewService()
|
||||
|
||||
before, err := service.VisibleLinkedObjects(task.ID, assigneeID)
|
||||
require.NoError(t, err)
|
||||
@@ -32,8 +32,8 @@ func TestAssigneeOnlySeesExplicitlySharedObjects(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShareObjectRejectsUnsupportedType(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewService(database)
|
||||
newTestDB(t)
|
||||
service := NewService()
|
||||
|
||||
err := service.ShareObject(1, "ai_session", 9)
|
||||
|
||||
@@ -42,11 +42,11 @@ func TestShareObjectRejectsUnsupportedType(t *testing.T) {
|
||||
|
||||
func TestShareObjectRejectsObjectFromAnotherProject(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
task := domain.Task{ProjectID: 1, CreatedBy: 1, Title: "Review"}
|
||||
note := domain.Note{ProjectID: 2, CreatedBy: 1, Title: "Other project", Markdown: "Private context"}
|
||||
task := models.SenlinAgentTask{ProjectID: 1, CreatedBy: 1, Title: "Review"}
|
||||
note := models.SenlinAgentNote{ProjectID: 2, CreatedBy: 1, Title: "Other project", Markdown: "Private context"}
|
||||
require.NoError(t, database.Create(&task).Error)
|
||||
require.NoError(t, database.Create(¬e).Error)
|
||||
service := NewService(database)
|
||||
service := NewService()
|
||||
|
||||
err := service.ShareObject(task.ID, "note", note.ID)
|
||||
|
||||
@@ -55,13 +55,13 @@ func TestShareObjectRejectsObjectFromAnotherProject(t *testing.T) {
|
||||
|
||||
func TestAssignRecordsProjectEvent(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
task := domain.Task{ProjectID: 7, CreatedBy: 1, Title: "安排评审"}
|
||||
task := models.SenlinAgentTask{ProjectID: 7, CreatedBy: 1, Title: "安排评审"}
|
||||
require.NoError(t, database.Create(&task).Error)
|
||||
service := NewService(database)
|
||||
service := NewService()
|
||||
|
||||
require.NoError(t, service.Assign(task.ID, 2))
|
||||
|
||||
var event domain.ProjectEvent
|
||||
var event models.SenlinAgentProjectEvent
|
||||
require.NoError(t, database.Where("project_id = ? AND entity_type = ? AND entity_id = ?", 7, "task", task.ID).First(&event).Error)
|
||||
require.Equal(t, "task_assigned", event.EventType)
|
||||
}
|
||||
@@ -70,6 +70,7 @@ func newTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, domain.AutoMigrate(database))
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
20
backend/internal/models/ai_call_log.go
Normal file
20
backend/internal/models/ai_call_log.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentAICallLog struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
UserID uint `gorm:"index;not null"`
|
||||
UserIdentity string `gorm:"type:char(36);index"`
|
||||
Provider string `gorm:"not null"`
|
||||
UsedKeyType string `gorm:"not null"`
|
||||
Action string `gorm:"not null"`
|
||||
Status string `gorm:"not null"`
|
||||
Error string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentAICallLog) TableName() string {
|
||||
return "senlin_agent_ai_call_logs"
|
||||
}
|
||||
18
backend/internal/models/ai_key.go
Normal file
18
backend/internal/models/ai_key.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentAIKey struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
UserID uint `gorm:"uniqueIndex;not null"`
|
||||
UserIdentity string `gorm:"type:char(36);index"`
|
||||
Provider string `gorm:"not null"`
|
||||
EncryptedAPIKey string `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentAIKey) TableName() string {
|
||||
return "senlin_agent_ai_keys"
|
||||
}
|
||||
20
backend/internal/models/ai_session.go
Normal file
20
backend/internal/models/ai_session.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentAISession struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
ProjectIdentity string `gorm:"type:char(36);index"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
CreatedByIdentity string `gorm:"type:char(36);index"`
|
||||
Title string `gorm:"not null"`
|
||||
Context string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentAISession) TableName() string {
|
||||
return "senlin_agent_ai_sessions"
|
||||
}
|
||||
202
backend/internal/models/identity.go
Normal file
202
backend/internal/models/identity.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (m *SenlinAgentUser) BeforeCreate(tx *gorm.DB) error {
|
||||
return ensureIdentity(&m.Identity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentProject) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveIdentity(tx, &SenlinAgentUser{}, m.OwnerID, &m.OwnerIdentity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentInboxItem) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentTask) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveOptionalIdentity(tx, &SenlinAgentUser{}, m.AssigneeID, &m.AssigneeIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveOptionalIdentity(tx, &SenlinAgentInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentNote) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveOptionalIdentity(tx, &SenlinAgentInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentSource) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveOptionalIdentity(tx, &SenlinAgentInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentAISession) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentTag) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentProjectEvent) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentProject{}, m.ProjectID, &m.ProjectIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentUser{}, m.ActorID, &m.ActorIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveEntityIdentity(tx, m.EntityType, m.EntityID, &m.EntityIdentity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentAIKey) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveIdentity(tx, &SenlinAgentUser{}, m.UserID, &m.UserIdentity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentAICallLog) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveIdentity(tx, &SenlinAgentUser{}, m.UserID, &m.UserIdentity)
|
||||
}
|
||||
|
||||
func (m *SenlinAgentTaskShare) BeforeCreate(tx *gorm.DB) error {
|
||||
if err := ensureIdentity(&m.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := resolveIdentity(tx, &SenlinAgentTask{}, m.TaskID, &m.TaskIdentity); err != nil {
|
||||
return err
|
||||
}
|
||||
return resolveEntityIdentity(tx, m.ObjectType, m.ObjectID, &m.ObjectIdentity)
|
||||
}
|
||||
|
||||
func ensureIdentity(identity *string) error {
|
||||
if *identity != "" {
|
||||
return nil
|
||||
}
|
||||
next, err := uuidV7()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*identity = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveIdentity(tx *gorm.DB, model any, id uint, identity *string) error {
|
||||
if id == 0 || *identity != "" {
|
||||
return nil
|
||||
}
|
||||
resolved, err := lookupIdentity(tx, model, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*identity = resolved
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveOptionalIdentity(tx *gorm.DB, model any, id *uint, identity **string) error {
|
||||
if id == nil || *id == 0 || (identity != nil && *identity != nil && **identity != "") {
|
||||
return nil
|
||||
}
|
||||
resolved, err := lookupIdentity(tx, model, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*identity = &resolved
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveEntityIdentity(tx *gorm.DB, entityType string, entityID uint, identity *string) error {
|
||||
if entityID == 0 || *identity != "" {
|
||||
return nil
|
||||
}
|
||||
switch entityType {
|
||||
case "project":
|
||||
return resolveIdentity(tx, &SenlinAgentProject{}, entityID, identity)
|
||||
case "inbox", "inbox_item":
|
||||
return resolveIdentity(tx, &SenlinAgentInboxItem{}, entityID, identity)
|
||||
case "task":
|
||||
return resolveIdentity(tx, &SenlinAgentTask{}, entityID, identity)
|
||||
case "note":
|
||||
return resolveIdentity(tx, &SenlinAgentNote{}, entityID, identity)
|
||||
case "source":
|
||||
return resolveIdentity(tx, &SenlinAgentSource{}, entityID, identity)
|
||||
case "ai_session":
|
||||
return resolveIdentity(tx, &SenlinAgentAISession{}, entityID, identity)
|
||||
case "tag":
|
||||
return resolveIdentity(tx, &SenlinAgentTag{}, entityID, identity)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func lookupIdentity(tx *gorm.DB, model any, id uint) (string, error) {
|
||||
var identity string
|
||||
err := tx.Model(model).Select("identity").Where("id = ?", id).Row().Scan(&identity)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return identity, err
|
||||
}
|
||||
|
||||
func uuidV7() (string, error) {
|
||||
next, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return next.String(), nil
|
||||
}
|
||||
49
backend/internal/models/identity_test.go
Normal file
49
backend/internal/models/identity_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBeforeCreateAssignsUUIDV7Identity(t *testing.T) {
|
||||
database := newIdentityTestDB(t)
|
||||
user := SenlinAgentUser{Email: "lead@example.com", DisplayName: "Lead", PasswordHash: "hash"}
|
||||
|
||||
require.NoError(t, database.Create(&user).Error)
|
||||
|
||||
require.NotEmpty(t, user.Identity)
|
||||
require.Equal(t, byte('7'), user.Identity[14])
|
||||
}
|
||||
|
||||
func TestBeforeCreateCopiesParentIdentitiesFromNumericIDs(t *testing.T) {
|
||||
database := newIdentityTestDB(t)
|
||||
user := SenlinAgentUser{Email: "lead@example.com", DisplayName: "Lead", PasswordHash: "hash"}
|
||||
require.NoError(t, database.Create(&user).Error)
|
||||
project := SenlinAgentProject{OwnerID: user.ID, Name: "Alpha"}
|
||||
require.NoError(t, database.Create(&project).Error)
|
||||
item := SenlinAgentInboxItem{ProjectID: project.ID, CreatedBy: user.ID, SourceType: "text"}
|
||||
require.NoError(t, database.Create(&item).Error)
|
||||
|
||||
task := SenlinAgentTask{ProjectID: project.ID, CreatedBy: user.ID, SourceInboxItemID: &item.ID, Title: "Follow up"}
|
||||
|
||||
require.NoError(t, database.Create(&task).Error)
|
||||
|
||||
require.NotEmpty(t, project.Identity)
|
||||
require.Equal(t, user.Identity, project.OwnerIdentity)
|
||||
require.Equal(t, project.Identity, task.ProjectIdentity)
|
||||
require.Equal(t, user.Identity, task.CreatedByIdentity)
|
||||
require.NotNil(t, task.SourceInboxItemIdentity)
|
||||
require.Equal(t, item.Identity, *task.SourceInboxItemIdentity)
|
||||
}
|
||||
|
||||
func newIdentityTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, AutoMigrate(database))
|
||||
return database
|
||||
}
|
||||
22
backend/internal/models/inbox_item.go
Normal file
22
backend/internal/models/inbox_item.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentInboxItem struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
ProjectIdentity string `gorm:"type:char(36);index"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
CreatedByIdentity string `gorm:"type:char(36);index"`
|
||||
SourceType string `gorm:"not null"`
|
||||
Title string
|
||||
Body string
|
||||
Status string `gorm:"not null;default:open"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentInboxItem) TableName() string {
|
||||
return "senlin_agent_inbox_items"
|
||||
}
|
||||
39
backend/internal/models/new.go
Normal file
39
backend/internal/models/new.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DBService *gorm.DB
|
||||
|
||||
func New(dsn string) error {
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := AutoMigrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
DBService = db
|
||||
return nil
|
||||
}
|
||||
|
||||
func AutoMigrate(database *gorm.DB) error {
|
||||
return database.AutoMigrate(
|
||||
&SenlinAgentUser{},
|
||||
&SenlinAgentProject{},
|
||||
&SenlinAgentInboxItem{},
|
||||
&SenlinAgentTask{},
|
||||
&SenlinAgentNote{},
|
||||
&SenlinAgentSource{},
|
||||
&SenlinAgentAISession{},
|
||||
&SenlinAgentTag{},
|
||||
&SenlinAgentProjectEvent{},
|
||||
&SenlinAgentAIKey{},
|
||||
&SenlinAgentAICallLog{},
|
||||
&SenlinAgentTaskShare{},
|
||||
)
|
||||
}
|
||||
22
backend/internal/models/note.go
Normal file
22
backend/internal/models/note.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentNote struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
ProjectIdentity string `gorm:"type:char(36);index"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
CreatedByIdentity string `gorm:"type:char(36);index"`
|
||||
SourceInboxItemID *uint `gorm:"index"`
|
||||
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
|
||||
Title string `gorm:"not null"`
|
||||
Markdown string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentNote) TableName() string {
|
||||
return "senlin_agent_notes"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build integration
|
||||
|
||||
package db
|
||||
package models
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -10,10 +10,13 @@ import (
|
||||
)
|
||||
|
||||
func TestPostgresPing(t *testing.T) {
|
||||
databaseURL := os.Getenv("DATABASE_URL")
|
||||
require.NotEmpty(t, databaseURL, "DATABASE_URL is required for integration tests")
|
||||
dsn := os.Getenv("DATABASE_DSN")
|
||||
if dsn == "" {
|
||||
dsn = os.Getenv("DATABASE_URL")
|
||||
}
|
||||
require.NotEmpty(t, dsn, "DATABASE_DSN or DATABASE_URL is required for integration tests")
|
||||
|
||||
database, err := Open(databaseURL)
|
||||
database, err := Open(dsn)
|
||||
require.NoError(t, err)
|
||||
sqlDB, err := database.DB()
|
||||
require.NoError(t, err)
|
||||
18
backend/internal/models/project.go
Normal file
18
backend/internal/models/project.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentProject struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
OwnerID uint `gorm:"index;not null"`
|
||||
OwnerIdentity string `gorm:"type:char(36);index"`
|
||||
Name string `gorm:"not null"`
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentProject) TableName() string {
|
||||
return "senlin_agent_projects"
|
||||
}
|
||||
22
backend/internal/models/project_event.go
Normal file
22
backend/internal/models/project_event.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentProjectEvent struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
ProjectIdentity string `gorm:"type:char(36);index"`
|
||||
ActorID uint `gorm:"index;not null"`
|
||||
ActorIdentity string `gorm:"type:char(36);index"`
|
||||
EventType string `gorm:"not null"`
|
||||
EntityType string `gorm:"not null"`
|
||||
EntityID uint `gorm:"not null;index"`
|
||||
EntityIdentity string `gorm:"type:char(36);index"`
|
||||
Summary string `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentProjectEvent) TableName() string {
|
||||
return "senlin_agent_project_events"
|
||||
}
|
||||
25
backend/internal/models/source.go
Normal file
25
backend/internal/models/source.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentSource struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
ProjectIdentity string `gorm:"type:char(36);index"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
CreatedByIdentity string `gorm:"type:char(36);index"`
|
||||
SourceInboxItemID *uint `gorm:"index"`
|
||||
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
|
||||
Kind string `gorm:"not null"`
|
||||
Title string `gorm:"not null"`
|
||||
URL string
|
||||
FilePath string
|
||||
ContentText string `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentSource) TableName() string {
|
||||
return "senlin_agent_sources"
|
||||
}
|
||||
16
backend/internal/models/tag.go
Normal file
16
backend/internal/models/tag.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentTag struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
ProjectIdentity string `gorm:"type:char(36);index"`
|
||||
Name string `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentTag) TableName() string {
|
||||
return "senlin_agent_tags"
|
||||
}
|
||||
27
backend/internal/models/task.go
Normal file
27
backend/internal/models/task.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentTask struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
ProjectID uint `gorm:"index;not null"`
|
||||
ProjectIdentity string `gorm:"type:char(36);index"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
CreatedByIdentity string `gorm:"type:char(36);index"`
|
||||
AssigneeID *uint `gorm:"index"`
|
||||
AssigneeIdentity *string `gorm:"type:char(36);index"`
|
||||
SourceInboxItemID *uint `gorm:"index"`
|
||||
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
|
||||
Title string `gorm:"not null"`
|
||||
Description string
|
||||
Status string `gorm:"not null;default:open"`
|
||||
SortOrder int `gorm:"not null;default:0"`
|
||||
DueAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentTask) TableName() string {
|
||||
return "senlin_agent_tasks"
|
||||
}
|
||||
18
backend/internal/models/task_share.go
Normal file
18
backend/internal/models/task_share.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentTaskShare struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
TaskID uint `gorm:"index;not null"`
|
||||
TaskIdentity string `gorm:"type:char(36);index"`
|
||||
ObjectType string `gorm:"not null"`
|
||||
ObjectID uint `gorm:"not null;index"`
|
||||
ObjectIdentity string `gorm:"type:char(36);index"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentTaskShare) TableName() string {
|
||||
return "senlin_agent_task_shares"
|
||||
}
|
||||
18
backend/internal/models/user.go
Normal file
18
backend/internal/models/user.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type SenlinAgentUser struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Identity string `gorm:"type:char(36);uniqueIndex"`
|
||||
Email string `gorm:"uniqueIndex;not null"`
|
||||
DisplayName string `gorm:"not null"`
|
||||
PasswordHash string `gorm:"not null"`
|
||||
Role string `gorm:"not null;default:user"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SenlinAgentUser) TableName() string {
|
||||
return "senlin_agent_users"
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewService(database *gorm.DB) *Service {
|
||||
return &Service{db: database}
|
||||
}
|
||||
|
||||
func (s *Service) CreateNote(projectID uint, userID uint, title string, markdown string) (*domain.Note, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" {
|
||||
return nil, errors.New("note title is required")
|
||||
}
|
||||
note := &domain.Note{ProjectID: projectID, CreatedBy: userID, Title: title, Markdown: markdown}
|
||||
return note, s.db.Create(note).Error
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package projects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type Dashboard struct {
|
||||
ProjectID uint `json:"project_id"`
|
||||
PendingInboxCount int64 `json:"pending_inbox_count"`
|
||||
OpenTaskCount int64 `json:"open_task_count"`
|
||||
RecentNoteCount int64 `json:"recent_note_count"`
|
||||
RecentSessionCount int64 `json:"recent_session_count"`
|
||||
}
|
||||
|
||||
func NewService(database *gorm.DB) *Service {
|
||||
return &Service{db: database}
|
||||
}
|
||||
|
||||
func (s *Service) CreateProject(ownerID uint, name string, description string) (*domain.Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("project name is required")
|
||||
}
|
||||
project := &domain.Project{OwnerID: ownerID, Name: name, Description: description}
|
||||
return project, s.db.Create(project).Error
|
||||
}
|
||||
|
||||
func (s *Service) ListProjects(ownerID uint) ([]domain.Project, error) {
|
||||
var projects []domain.Project
|
||||
err := s.db.Where("owner_id = ?", ownerID).Order("updated_at desc").Find(&projects).Error
|
||||
return projects, err
|
||||
}
|
||||
|
||||
func (s *Service) CreateTag(projectID uint, name string) (*domain.Tag, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, errors.New("tag name is required")
|
||||
}
|
||||
tag := &domain.Tag{ProjectID: projectID, Name: name}
|
||||
return tag, s.db.Create(tag).Error
|
||||
}
|
||||
|
||||
func (s *Service) ListTags(projectID uint) ([]domain.Tag, error) {
|
||||
var tags []domain.Tag
|
||||
err := s.db.Where("project_id = ?", projectID).Order("name asc").Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
|
||||
var project domain.Project
|
||||
if err := s.db.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil {
|
||||
return Dashboard{}, err
|
||||
}
|
||||
dashboard := Dashboard{ProjectID: projectID}
|
||||
if err := s.db.Model(&domain.InboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := s.db.Model(&domain.Task{}).Where("project_id = ? AND status <> ?", projectID, "done").Count(&dashboard.OpenTaskCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := s.db.Model(&domain.Note{}).Where("project_id = ?", projectID).Count(&dashboard.RecentNoteCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
if err := s.db.Model(&domain.AISession{}).Where("project_id = ?", projectID).Count(&dashboard.RecentSessionCount).Error; err != nil {
|
||||
return dashboard, err
|
||||
}
|
||||
return dashboard, nil
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Type string `json:"type"`
|
||||
ID uint `json:"id"`
|
||||
ProjectID uint `json:"project_id"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
}
|
||||
|
||||
func NewService(database *gorm.DB) *Service {
|
||||
return &Service{db: database}
|
||||
}
|
||||
|
||||
func (s *Service) Search(userID uint, query string) ([]Result, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return []Result{}, nil
|
||||
}
|
||||
if s.db.Dialector.Name() == "postgres" {
|
||||
return s.searchPostgres(userID, query)
|
||||
}
|
||||
|
||||
like := "%" + query + "%"
|
||||
results := []Result{}
|
||||
var projects []domain.Project
|
||||
if err := s.db.Where("owner_id = ? AND (name LIKE ? OR description LIKE ?)", userID, like, like).Find(&projects).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, project := range projects {
|
||||
results = append(results, Result{Type: "project", ID: project.ID, ProjectID: project.ID, Title: project.Name, Snippet: project.Description})
|
||||
}
|
||||
|
||||
var tasks []domain.Task
|
||||
if err := s.db.Joins("JOIN projects ON projects.id = tasks.project_id").
|
||||
Where("projects.owner_id = ? AND (tasks.title LIKE ? OR tasks.description LIKE ?)", userID, like, like).
|
||||
Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
results = append(results, Result{Type: "task", ID: task.ID, ProjectID: task.ProjectID, Title: task.Title, Snippet: task.Description})
|
||||
}
|
||||
|
||||
var notes []domain.Note
|
||||
if err := s.db.Joins("JOIN projects ON projects.id = notes.project_id").
|
||||
Where("projects.owner_id = ? AND (notes.title LIKE ? OR notes.markdown LIKE ?)", userID, like, like).
|
||||
Find(¬es).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, note := range notes {
|
||||
results = append(results, Result{Type: "note", ID: note.ID, ProjectID: note.ProjectID, Title: note.Title, Snippet: note.Markdown})
|
||||
}
|
||||
|
||||
var sources []domain.Source
|
||||
if err := s.db.Joins("JOIN projects ON projects.id = sources.project_id").
|
||||
Where("projects.owner_id = ? AND (sources.title LIKE ? OR sources.url LIKE ? OR sources.content_text LIKE ?)", userID, like, like, like).
|
||||
Find(&sources).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, source := range sources {
|
||||
results = append(results, Result{Type: "source", ID: source.ID, ProjectID: source.ProjectID, Title: source.Title, Snippet: source.ContentText})
|
||||
}
|
||||
|
||||
var inboxItems []domain.InboxItem
|
||||
if err := s.db.Joins("JOIN projects ON projects.id = inbox_items.project_id").
|
||||
Where("projects.owner_id = ? AND (inbox_items.title LIKE ? OR inbox_items.body LIKE ?)", userID, like, like).
|
||||
Find(&inboxItems).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range inboxItems {
|
||||
results = append(results, Result{Type: "inbox", ID: item.ID, ProjectID: item.ProjectID, Title: item.Title, Snippet: item.Body})
|
||||
}
|
||||
|
||||
var sessions []domain.AISession
|
||||
if err := s.db.Joins("JOIN projects ON projects.id = ai_sessions.project_id").
|
||||
Where("projects.owner_id = ? AND (ai_sessions.title LIKE ? OR ai_sessions.context LIKE ?)", userID, like, like).
|
||||
Find(&sessions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
results = append(results, Result{Type: "ai_session", ID: session.ID, ProjectID: session.ProjectID, Title: session.Title, Snippet: session.Context})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) searchPostgres(userID uint, query string) ([]Result, error) {
|
||||
var results []Result
|
||||
err := s.db.Raw(`
|
||||
SELECT 'project' AS type, projects.id, projects.id AS project_id, projects.name AS title, projects.description AS snippet
|
||||
FROM projects
|
||||
WHERE projects.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(projects.name, '') || ' ' || coalesce(projects.description, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'task' AS type, tasks.id, tasks.project_id, tasks.title, tasks.description AS snippet
|
||||
FROM tasks
|
||||
JOIN projects ON projects.id = tasks.project_id
|
||||
WHERE projects.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(tasks.title, '') || ' ' || coalesce(tasks.description, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'note' AS type, notes.id, notes.project_id, notes.title, notes.markdown AS snippet
|
||||
FROM notes
|
||||
JOIN projects ON projects.id = notes.project_id
|
||||
WHERE projects.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(notes.title, '') || ' ' || coalesce(notes.markdown, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'source' AS type, sources.id, sources.project_id, sources.title, sources.content_text AS snippet
|
||||
FROM sources
|
||||
JOIN projects ON projects.id = sources.project_id
|
||||
WHERE projects.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(sources.title, '') || ' ' || coalesce(sources.url, '') || ' ' || coalesce(sources.content_text, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'inbox' AS type, inbox_items.id, inbox_items.project_id, inbox_items.title, inbox_items.body AS snippet
|
||||
FROM inbox_items
|
||||
JOIN projects ON projects.id = inbox_items.project_id
|
||||
WHERE projects.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(inbox_items.title, '') || ' ' || coalesce(inbox_items.body, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'ai_session' AS type, ai_sessions.id, ai_sessions.project_id, ai_sessions.title, ai_sessions.context AS snippet
|
||||
FROM ai_sessions
|
||||
JOIN projects ON projects.id = ai_sessions.project_id
|
||||
WHERE projects.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(ai_sessions.title, '') || ' ' || coalesce(ai_sessions.context, '')) @@ plainto_tsquery('simple', ?)
|
||||
LIMIT 50
|
||||
`, userID, query, userID, query, userID, query, userID, query, userID, query, userID, query).Scan(&results).Error
|
||||
return results, err
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestSearchFindsNoteBody(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, domain.AutoMigrate(database))
|
||||
require.NoError(t, database.Create(&domain.Project{ID: 1, OwnerID: 7, Name: "支付项目"}).Error)
|
||||
require.NoError(t, database.Create(&domain.Note{ProjectID: 1, CreatedBy: 7, Title: "接口方案", Markdown: "二维码支付回调设计"}).Error)
|
||||
|
||||
service := NewService(database)
|
||||
results, err := service.Search(7, "回调")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
require.Equal(t, "note", results[0].Type)
|
||||
}
|
||||
|
||||
func TestSearchFindsCoreProjectObjects(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, domain.AutoMigrate(database))
|
||||
require.NoError(t, database.Create(&domain.Project{ID: 1, OwnerID: 7, Name: "支付项目"}).Error)
|
||||
require.NoError(t, database.Create(&domain.Task{ProjectID: 1, CreatedBy: 7, Title: "回调任务", Description: "检查 webhook"}).Error)
|
||||
require.NoError(t, database.Create(&domain.Source{ProjectID: 1, CreatedBy: 7, Kind: "link", Title: "支付文档", URL: "https://example.com/pay", ContentText: "webhook 签名"}).Error)
|
||||
require.NoError(t, database.Create(&domain.InboxItem{ProjectID: 1, CreatedBy: 7, SourceType: "text", Title: "收集项", Body: "webhook 待整理"}).Error)
|
||||
require.NoError(t, database.Create(&domain.AISession{ProjectID: 1, CreatedBy: 7, Title: "AI 分析", Context: "webhook 问答"}).Error)
|
||||
|
||||
service := NewService(database)
|
||||
results, err := service.Search(7, "webhook")
|
||||
|
||||
require.NoError(t, err)
|
||||
types := make(map[string]bool)
|
||||
for _, result := range results {
|
||||
types[result.Type] = true
|
||||
}
|
||||
require.True(t, types["task"])
|
||||
require.True(t, types["source"])
|
||||
require.True(t, types["inbox"])
|
||||
require.True(t, types["ai_session"])
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_search
|
||||
ON projects
|
||||
CREATE INDEX IF NOT EXISTS idx_senlin_agent_projects_search
|
||||
ON senlin_agent_projects
|
||||
USING gin (to_tsvector('simple', coalesce(name, '') || ' ' || coalesce(description, '')));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_search
|
||||
ON notes
|
||||
CREATE INDEX IF NOT EXISTS idx_senlin_agent_notes_search
|
||||
ON senlin_agent_notes
|
||||
USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(markdown, '')));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sources_search
|
||||
ON sources
|
||||
CREATE INDEX IF NOT EXISTS idx_senlin_agent_sources_search
|
||||
ON senlin_agent_sources
|
||||
USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(url, '') || ' ' || coalesce(content_text, '')));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inbox_items_search
|
||||
ON inbox_items
|
||||
CREATE INDEX IF NOT EXISTS idx_senlin_agent_inbox_items_search
|
||||
ON senlin_agent_inbox_items
|
||||
USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(body, '')));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_search
|
||||
ON tasks
|
||||
CREATE INDEX IF NOT EXISTS idx_senlin_agent_tasks_search
|
||||
ON senlin_agent_tasks
|
||||
USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(description, '')));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_sessions_search
|
||||
ON ai_sessions
|
||||
CREATE INDEX IF NOT EXISTS idx_senlin_agent_ai_sessions_search
|
||||
ON senlin_agent_ai_sessions
|
||||
USING gin (to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(context, '')));
|
||||
|
||||
227
design.md
Normal file
227
design.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# SenlinAI UI Design Guide
|
||||
|
||||
## Purpose
|
||||
|
||||
This file defines the shared visual direction for SenlinAI Workbench. It keeps future UI work consistent across the login page, project shell, channel pages, object inspector, and desktop wrapper.
|
||||
|
||||
The visual reference is shadcn/ui: quiet, structured, component-driven, and practical. This is a visual reference only. The web app must remain Svelte and TypeScript, and must not add React or shadcn/ui as runtime dependencies.
|
||||
|
||||
## Product Feel
|
||||
|
||||
SenlinAI is a private project workbench for focused knowledge work. The UI should feel calm, precise, and durable rather than decorative. It should support long sessions, dense information, fast scanning, and frequent switching between projects and channels.
|
||||
|
||||
Use a restrained desktop-app tone:
|
||||
|
||||
- Clear hierarchy.
|
||||
- Compact but breathable spacing.
|
||||
- Low-noise borders and surfaces.
|
||||
- Strong focus states.
|
||||
- Predictable controls.
|
||||
- Minimal decoration.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Marketing-style hero sections inside the app.
|
||||
- Large decorative gradients.
|
||||
- Floating page sections styled as cards.
|
||||
- Nested cards.
|
||||
- One-color themes dominated by a single hue.
|
||||
- Purely visual icon buttons without labels for assistive technology.
|
||||
|
||||
## Layout Principles
|
||||
|
||||
The main workbench uses a Discord-like project model:
|
||||
|
||||
- Project rail: narrow, persistent, dynamic project list.
|
||||
- Channel sidebar: current project navigation and recent sessions.
|
||||
- Main content: channel-specific page.
|
||||
- Object inspector: discussion, properties, and more actions.
|
||||
- Topbar: search and global navigation.
|
||||
|
||||
Desktop is the primary layout. Mobile should remain usable by collapsing the project rail, channel sidebar, and inspector into drawers or full-screen panels.
|
||||
|
||||
Use stable dimensions for fixed UI elements:
|
||||
|
||||
- Project icons: fixed square or circle size.
|
||||
- Channel rows: fixed minimum height.
|
||||
- Icon buttons: fixed square hit area.
|
||||
- Task cards: stable padding and content rhythm.
|
||||
- Inspector tabs: consistent height.
|
||||
|
||||
## Color
|
||||
|
||||
Use neutral surfaces first:
|
||||
|
||||
- App background: soft gray.
|
||||
- Panels and toolbars: white or near-white.
|
||||
- Borders: light neutral gray.
|
||||
- Primary text: near-black neutral.
|
||||
- Secondary text: muted gray.
|
||||
|
||||
Use one clear brand/action blue for:
|
||||
|
||||
- Primary buttons.
|
||||
- Active channel indicator.
|
||||
- Selected tabs.
|
||||
- Focus-visible outlines when appropriate.
|
||||
|
||||
Use semantic colors sparingly:
|
||||
|
||||
- Success: completed tasks and successful sync states.
|
||||
- Warning: delayed tasks or partial states.
|
||||
- Danger: destructive actions and errors.
|
||||
|
||||
Do not use color as the only state indicator. Pair color with text, icon shape, or position.
|
||||
|
||||
## Typography
|
||||
|
||||
Use the existing system font stack unless a design-system font is explicitly added later.
|
||||
|
||||
Typography should be compact and readable:
|
||||
|
||||
- Page title: clear but not oversized.
|
||||
- Panel title: medium weight.
|
||||
- Row/card title: strong enough for scanning.
|
||||
- Metadata: smaller and muted.
|
||||
- Button text: short and action-oriented.
|
||||
|
||||
Do not scale font size with viewport width. Letter spacing should stay at `0`.
|
||||
|
||||
## Spacing
|
||||
|
||||
Use a small spacing scale:
|
||||
|
||||
- 4px for tight internal gaps.
|
||||
- 8px for standard control gaps.
|
||||
- 12px for row and card content spacing.
|
||||
- 16px for panel padding.
|
||||
- 24px for major page spacing.
|
||||
|
||||
Dense work surfaces can use tighter spacing, but text and controls must not feel cramped.
|
||||
|
||||
## Radius And Borders
|
||||
|
||||
Use shadcn-like restrained shapes:
|
||||
|
||||
- Inputs, buttons, tabs, cards, menus: 6px to 8px radius.
|
||||
- Project avatars can be circular when matching the Discord-like rail.
|
||||
- Avoid large pill shapes except for count badges or compact status labels.
|
||||
|
||||
Use borders to define structure before shadows. Shadows should be subtle and reserved for menus, dialogs, popovers, and floating inspectors.
|
||||
|
||||
## Components
|
||||
|
||||
Buttons:
|
||||
|
||||
- Primary button for the main action on a surface.
|
||||
- Secondary/outline button for supporting actions.
|
||||
- Ghost button for toolbar and icon actions.
|
||||
- Destructive button only for irreversible actions.
|
||||
|
||||
Inputs:
|
||||
|
||||
- Labels must be explicit.
|
||||
- Errors must appear near the field.
|
||||
- Focus state must be visible.
|
||||
|
||||
Tabs:
|
||||
|
||||
- Use tabs for Discussion, Properties, and More in the inspector.
|
||||
- Active tab must be visually clear and keyboard accessible.
|
||||
|
||||
Cards:
|
||||
|
||||
- Use cards for tasks, repeated records, and object summaries.
|
||||
- Do not place cards inside cards.
|
||||
- Keep task cards compact, with complete/incomplete state visible.
|
||||
|
||||
Badges:
|
||||
|
||||
- Use badges for counts, status, tags, and source type.
|
||||
- Count badges in the channel sidebar should be compact and aligned.
|
||||
|
||||
Menus:
|
||||
|
||||
- Use menus for secondary actions.
|
||||
- Destructive actions should be separated or clearly styled.
|
||||
|
||||
## Channel Templates
|
||||
|
||||
Each system channel has its own page template.
|
||||
|
||||
Message Flow:
|
||||
|
||||
- Similar to email.
|
||||
- Use a list of messages with sender/source, subject, summary, timestamp, status, and tags.
|
||||
- Selected message opens details in the inspector.
|
||||
|
||||
Work Plan:
|
||||
|
||||
- Similar to Todo software.
|
||||
- Use task cards with complete/incomplete controls.
|
||||
- Show owner, due date, tags, and status.
|
||||
|
||||
AI Sessions:
|
||||
|
||||
- Use a session list plus selected conversation detail.
|
||||
- Show context references and save/convert actions clearly.
|
||||
|
||||
Notes And Sources:
|
||||
|
||||
- Similar to file and attachment management.
|
||||
- Support list and metadata-heavy rows.
|
||||
- Show file type, note/source type, updated time, tags, and source.
|
||||
|
||||
Cron Plans:
|
||||
|
||||
- Similar to scheduled task management.
|
||||
- Show enabled state, schedule, next run, last result, and owner.
|
||||
- Keep this as plan/reminder management unless a later spec expands backend execution.
|
||||
|
||||
Custom Channel:
|
||||
|
||||
- Use title, icon, and URL.
|
||||
- Show open and copy actions.
|
||||
- Do not make it a separate content system in MVP.
|
||||
|
||||
## Login Page
|
||||
|
||||
The login page should feel like a private deployment entry point:
|
||||
|
||||
- Server address first.
|
||||
- Email or username.
|
||||
- Password.
|
||||
- Clear login button.
|
||||
- Connection and error state.
|
||||
- Remembered server address.
|
||||
|
||||
Avoid a marketing landing page. The first screen should let the user log in.
|
||||
|
||||
## Accessibility
|
||||
|
||||
Every icon button needs an accessible name.
|
||||
|
||||
Keyboard users must be able to:
|
||||
|
||||
- Move through project rail items.
|
||||
- Move through channel rows.
|
||||
- Search.
|
||||
- Switch inspector tabs.
|
||||
- Toggle task completion.
|
||||
- Open menus.
|
||||
- Submit login.
|
||||
|
||||
Focus-visible states must be clear. Error states must use text and not color alone.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Use Svelte components and local CSS. A future design token file may be introduced, but until then, keep colors, spacing, radius, and typography consistent with this document.
|
||||
|
||||
Before finishing UI work, check:
|
||||
|
||||
- Text does not overflow buttons, cards, sidebars, or tabs.
|
||||
- Mobile layout remains usable.
|
||||
- Active project and active channel are obvious.
|
||||
- Counts and statuses are readable.
|
||||
- Empty states explain what the user can do next.
|
||||
- The UI does not depend on placeholder boxes or decorative fake assets.
|
||||
BIN
docs/ScreenShot.png
Normal file
BIN
docs/ScreenShot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
@@ -19,7 +19,7 @@ Run:
|
||||
|
||||
```powershell
|
||||
Set-Location backend
|
||||
go test -tags integration ./internal/db -run TestPostgresPing -v
|
||||
go test -tags integration ./internal/models -run TestPostgresPing -v
|
||||
```
|
||||
|
||||
Expected: `TestPostgresPing` passes.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
# Discord 式项目频道工作台设计
|
||||
|
||||
## 背景
|
||||
|
||||
本设计基于 `docs/ScreenShot.png` 中的产品原型,并结合 SenlinAI 项目工作台 MVP 的既有边界。新的登录后主页面保留截图中的三栏工作台结构,但交互模型更接近 Discord:左侧项目列表类似 server 列表,用户切换项目后,当前项目的频道、计数、最近会话和主内容区随项目动态加载。
|
||||
|
||||
本次设计只定义 Web 客户端的产品原型和前端体验,不扩大后端 MVP 范围。AI 不得未经用户确认创建正式对象;Cron 计划任务在 MVP 中只作为项目内计划任务/提醒管理,不实现自主 Agent 执行。
|
||||
|
||||
## 用户目标
|
||||
|
||||
用户需要在一个项目内快速切换不同工作模式:收集消息、管理计划、查看 AI 会话、整理笔记资料、管理定时计划,并通过自定义频道保存项目相关外部入口。跨项目时,用户希望像切换 Discord server 一样,看到该项目自己的频道和数据。
|
||||
|
||||
## 核心体验
|
||||
|
||||
应用分为登录页和登录后工作台。
|
||||
|
||||
登录页提供完整登录体验:
|
||||
|
||||
- 服务器 IP 或域名输入。
|
||||
- 邮箱或用户名输入。
|
||||
- 密码输入。
|
||||
- 登录按钮、连接状态和错误提示。
|
||||
- 记住服务器地址,方便私有化部署环境下重复登录。
|
||||
|
||||
登录后工作台由五个区域组成:
|
||||
|
||||
- 顶部全局栏:Logo、跨项目搜索、浏览器式导航动作、账号入口。
|
||||
- 左侧项目栏:动态项目列表、创建项目入口、当前用户区。
|
||||
- 项目频道栏:当前项目名称、项目设置、系统频道、自定义频道、最近会话。
|
||||
- 主内容区:根据当前频道展示不同页面模板。
|
||||
- 右侧详情栏:展示当前选中对象的讨论、属性和更多操作。
|
||||
|
||||
## 信息架构
|
||||
|
||||
### 项目栏
|
||||
|
||||
项目栏展示用户可访问的项目。每个项目显示图标或首字母、项目名提示和未处理计数。点击项目后,应用加载该项目下的频道、标签、最近会话和默认频道。
|
||||
|
||||
项目栏固定包含:
|
||||
|
||||
- 面板入口。
|
||||
- 动态项目列表。
|
||||
- 创建项目按钮。
|
||||
- 当前用户区,包括账号、好友/内部用户、私信和设置入口。
|
||||
|
||||
MVP 中项目列表应来自 API 或前端 mock 数据适配层,不写死为单个项目。
|
||||
|
||||
### 项目频道栏
|
||||
|
||||
频道栏属于当前项目。切换项目后,频道栏整体刷新。
|
||||
|
||||
系统频道:
|
||||
|
||||
- 概况。
|
||||
- 消息流。
|
||||
- 工作计划。
|
||||
- AI 会话。
|
||||
- 笔记资料。
|
||||
- Cron 计划任务。
|
||||
|
||||
自定义频道:
|
||||
|
||||
- 标题。
|
||||
- 图标。
|
||||
- URL 地址。
|
||||
- 排序字段。
|
||||
|
||||
自定义频道 MVP 是项目内快捷入口,不承载独立消息或任务数据。点击后在主区展示外部链接入口,可以提供复制链接、在新窗口打开;内嵌预览作为后续能力,不进入本次 MVP。
|
||||
|
||||
### 标签栏
|
||||
|
||||
截图顶部的标签行保留为当前项目内筛选器。标签只限定在项目内,不引入全局标签体系。标签可用于筛选消息流、任务、笔记资料和 AI 会话。
|
||||
|
||||
## 频道页面设计
|
||||
|
||||
### 概况
|
||||
|
||||
概况是项目默认首页,展示项目状态总览。
|
||||
|
||||
内容包括:
|
||||
|
||||
- 待处理消息数量。
|
||||
- 未完成任务数量。
|
||||
- 最近 AI 会话。
|
||||
- 最近笔记资料。
|
||||
- 最近 Cron 计划。
|
||||
- 最近项目事件。
|
||||
|
||||
概况页用于快速扫视,不承担复杂编辑。
|
||||
|
||||
### 消息流
|
||||
|
||||
消息流类似邮件客户端,而不是聊天流。它用于收集和处理项目 inbox 条目。
|
||||
|
||||
页面结构:
|
||||
|
||||
- 左侧或主列表展示消息条目。
|
||||
- 每条消息显示来源、标题、摘要、状态、标签、时间。
|
||||
- 支持未处理、已处理、已归档等筛选。
|
||||
- 选中消息后,右侧详情栏显示正文、来源信息、AI 整理建议和讨论。
|
||||
|
||||
关键行为:
|
||||
|
||||
- 用户可以新增文本、链接或附件型消息。
|
||||
- 用户可以触发 AI 整理。
|
||||
- AI 返回任务、笔记、资料等候选建议。
|
||||
- 用户勾选并确认后才创建正式对象。
|
||||
- 生成对象保留来源 inbox item ID。
|
||||
|
||||
### 工作计划
|
||||
|
||||
工作计划类似 Todo 软件,使用卡片样式呈现任务。
|
||||
|
||||
页面结构:
|
||||
|
||||
- 未完成和已完成任务可以分组展示。
|
||||
- 每个任务是一张卡片。
|
||||
- 卡片包含完成/未完成标记、标题、描述摘要、负责人、截止时间、标签。
|
||||
- 支持按状态、负责人、标签筛选。
|
||||
|
||||
关键行为:
|
||||
|
||||
- 点击完成标记切换状态。
|
||||
- 点击卡片后,右侧详情栏显示任务属性、关联对象、讨论和分享设置。
|
||||
- 任务可显式分享关联的笔记或资料。
|
||||
|
||||
MVP 使用列表式卡片,不做复杂看板拖拽。
|
||||
|
||||
### AI 会话
|
||||
|
||||
AI 会话页包含会话列表和会话详情。
|
||||
|
||||
页面结构:
|
||||
|
||||
- 左侧列表显示当前项目的 AI 会话。
|
||||
- 主区显示当前会话消息、输入框和引用上下文。
|
||||
- 右侧详情栏显示会话属性、引用对象、可保存的 AI 输出和讨论。
|
||||
|
||||
关键行为:
|
||||
|
||||
- AI 会话归属于当前项目。
|
||||
- 用户可以引用项目内笔记和资料。
|
||||
- AI 输出可以保存到 inbox、转换为笔记或关联到任务,但正式创建前需要用户确认。
|
||||
|
||||
AI 会话不是自主 Agent 执行系统。
|
||||
|
||||
### 笔记资料
|
||||
|
||||
笔记资料页类似文件和附件管理器。
|
||||
|
||||
页面结构:
|
||||
|
||||
- 列表或网格展示笔记、文件、链接资料。
|
||||
- 每项显示类型、标题、更新时间、标签和来源。
|
||||
- 主区支持预览 Markdown 笔记、附件元数据和链接资料。
|
||||
- 右侧详情栏显示属性、标签、来源、讨论和关联任务。
|
||||
|
||||
关键行为:
|
||||
|
||||
- Markdown 笔记可编辑。
|
||||
- 文件和链接作为资料对象管理。
|
||||
- 附件上传路径仍由后端文件服务集中处理,前端不构造服务端存储路径。
|
||||
|
||||
### Cron 计划任务
|
||||
|
||||
Cron 计划任务页类似定时计划任务管理。
|
||||
|
||||
页面结构:
|
||||
|
||||
- 计划任务列表。
|
||||
- 每项显示名称、启用状态、周期表达、下次执行时间、最近结果和标签。
|
||||
- 右侧详情栏显示计划说明、历史记录和讨论。
|
||||
|
||||
MVP 限制:
|
||||
|
||||
- 可展示和编辑计划任务元数据。
|
||||
- 可标记启用/停用。
|
||||
- 不实现自主 Agent 执行。
|
||||
- 不实现复杂调度运行器;如后端尚无能力,前端可先作为计划任务管理 UI 和 mock 状态。
|
||||
|
||||
### 自定义频道
|
||||
|
||||
自定义频道是项目内 URL 快捷入口。
|
||||
|
||||
字段:
|
||||
|
||||
- 标题。
|
||||
- 图标。
|
||||
- URL 地址。
|
||||
- 排序。
|
||||
|
||||
页面结构:
|
||||
|
||||
- 主区显示频道标题、URL、打开按钮、复制按钮和基础说明。
|
||||
- 如未来需要,可支持内嵌 Web 预览,但 MVP 不依赖该能力。
|
||||
|
||||
## 右侧详情栏
|
||||
|
||||
右侧详情栏随当前选中对象变化。顶部使用 tabs:
|
||||
|
||||
- 讨论。
|
||||
- 属性。
|
||||
- 更多。
|
||||
|
||||
讨论不是实时 IM,而是对象级评论或协作记录。属性显示当前对象字段,例如任务负责人、截止时间、标签、消息来源、资料类型等。更多用于分享、复制链接、归档、删除等动作。
|
||||
|
||||
当当前频道没有选中对象时,右侧栏显示频道说明和可用操作。
|
||||
|
||||
## 状态模型
|
||||
|
||||
前端需要区分三类状态:
|
||||
|
||||
- 当前登录状态:服务器地址、token、当前用户。
|
||||
- 工作台状态:当前项目、当前频道、选中对象、打开的浏览器式标签页。
|
||||
- 草稿状态:Markdown 编辑草稿、AI 输入草稿、消息输入草稿。
|
||||
|
||||
浏览器式标签页保存页面级工作台状态。编辑器草稿和 AI 输入草稿不应只依赖标签页状态,避免用户切换项目或频道时丢失输入。
|
||||
|
||||
## 响应式规则
|
||||
|
||||
桌面端优先。
|
||||
|
||||
桌面布局:
|
||||
|
||||
- 左侧项目栏固定窄栏。
|
||||
- 项目频道栏固定宽度。
|
||||
- 主内容区自适应。
|
||||
- 右侧详情栏可折叠。
|
||||
|
||||
移动布局:
|
||||
|
||||
- 项目栏和频道栏折叠为抽屉。
|
||||
- 主内容区优先展示。
|
||||
- 右侧详情栏变为底部或全屏详情页。
|
||||
|
||||
## 可访问性要求
|
||||
|
||||
- 所有图标按钮必须有 `aria-label` 或可见文本。
|
||||
- 当前项目、当前频道、当前 tab 必须有明确选中状态。
|
||||
- 搜索框、频道列表、任务完成按钮、右侧 tabs 必须支持键盘访问。
|
||||
- 状态徽标不能只依赖颜色表达。
|
||||
- 表单错误需要文本说明,并通过 `aria-live` 或等价方式通知。
|
||||
|
||||
## 视觉风格
|
||||
|
||||
UI 视觉风格参考 shadcn/ui 的克制、清晰、组件化表达,但前端仍只使用 Svelte,不引入 React 或 shadcn/ui 依赖。视觉统一规则以仓库根目录 `design.md` 为准。
|
||||
|
||||
后续页面和组件应遵循:
|
||||
|
||||
- 中性色为主,少量品牌色用于选中态、主按钮和关键状态。
|
||||
- 卡片、输入框、列表、tabs、菜单、弹窗等控件保持一致的边框、圆角、间距和焦点状态。
|
||||
- 工作台页面优先信息密度和可扫描性,避免营销页式大 hero、装饰性渐变和过度卡片化。
|
||||
- 图标优先使用语义明确的按钮图标,文字仅用于清晰命令或需要解释的动作。
|
||||
- 每个频道页面可以有不同布局,但基础控件和状态表达必须一致。
|
||||
|
||||
## 技术落点
|
||||
|
||||
前端仍使用 Svelte 和 TypeScript,不引入 React。
|
||||
|
||||
建议组件划分:
|
||||
|
||||
- `ServerLogin.svelte`:完整登录页。
|
||||
- `ProjectWorkbench.svelte`:登录后的工作台外壳。
|
||||
- `ProjectRail.svelte`:动态项目列表。
|
||||
- `ProjectChannelSidebar.svelte`:当前项目频道栏。
|
||||
- `WorkspaceTopbar.svelte`:顶部搜索和导航。
|
||||
- `ChannelContent.svelte`:按频道类型分发页面。
|
||||
- `InboxChannel.svelte`。
|
||||
- `TasksChannel.svelte`。
|
||||
- `AISessionsChannel.svelte`。
|
||||
- `NotesSourcesChannel.svelte`。
|
||||
- `CronChannel.svelte`。
|
||||
- `CustomLinkChannel.svelte`。
|
||||
- `ObjectInspector.svelte`:右侧讨论/属性/更多面板。
|
||||
|
||||
API 适配建议:
|
||||
|
||||
- 复用现有项目 dashboard API。
|
||||
- 项目列表、频道列表和自定义频道可以先使用前端 mock 数据结构,后续接入后端。
|
||||
- 频道类型使用稳定枚举:`overview`、`inbox`、`tasks`、`ai_sessions`、`notes_sources`、`cron`、`custom_link`。
|
||||
|
||||
## 验证
|
||||
|
||||
Web 变更应在 `apps/web` 目录运行:
|
||||
|
||||
```powershell
|
||||
npm test -- --run
|
||||
npm run build
|
||||
```
|
||||
|
||||
如果改动影响主要工作台流程,应补充或更新 Playwright smoke test,覆盖:
|
||||
|
||||
- 登录页渲染服务器地址输入。
|
||||
- 登录后显示动态项目栏。
|
||||
- 切换项目后频道栏刷新。
|
||||
- 切换不同频道后主内容模板变化。
|
||||
- 自定义频道显示 URL 入口。
|
||||
|
||||
## 风险和边界
|
||||
|
||||
- 不把 Cron 计划任务扩展为自主 Agent 执行。
|
||||
- 不把对象讨论扩展为实时聊天。
|
||||
- 不做项目级成员角色体系。
|
||||
- 不做匿名公开分享。
|
||||
- 不做语义搜索或向量搜索。
|
||||
- 自定义频道 MVP 只做 URL 快捷入口。
|
||||
|
||||
## 自审
|
||||
|
||||
完整性检查:本文没有未解释的临时内容。
|
||||
|
||||
一致性检查:频道类型、页面模板、右侧详情栏和项目动态加载模型一致;自定义频道被限定为 URL 入口,没有和系统频道混淆。
|
||||
|
||||
范围检查:本设计只覆盖登录页和登录后主页面原型,不引入后端权限、实时聊天、自主 Agent 或全局标签等额外系统。
|
||||
|
||||
歧义检查:Cron 计划任务被明确限定为计划任务/提醒管理;AI 会话被明确限定为项目会话,不是自主执行。
|
||||
11
scripts/build-all.sh
Executable file
11
scripts/build-all.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
"$ROOT_DIR/scripts/build-backend.sh"
|
||||
"$ROOT_DIR/scripts/build-web.sh"
|
||||
"$ROOT_DIR/scripts/build-desktop.sh"
|
||||
|
||||
echo "All builds completed."
|
||||
|
||||
14
scripts/build-backend.sh
Executable file
14
scripts/build-backend.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT_DIR="$ROOT_DIR/backend/bin"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
cd "$ROOT_DIR/backend"
|
||||
go test ./...
|
||||
go build -o "$OUT_DIR/senlin-agent-api" ./cmd/api
|
||||
|
||||
echo "Backend binary: $OUT_DIR/senlin-agent-api"
|
||||
|
||||
10
scripts/build-desktop.sh
Executable file
10
scripts/build-desktop.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
cd "$ROOT_DIR/apps/desktop"
|
||||
npm run build
|
||||
|
||||
echo "Desktop build completed."
|
||||
|
||||
10
scripts/build-web.sh
Executable file
10
scripts/build-web.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
cd "$ROOT_DIR/apps/web"
|
||||
npm run build
|
||||
|
||||
echo "Web build output: $ROOT_DIR/apps/web/dist"
|
||||
|
||||
32
scripts/dev-all.sh
Executable file
32
scripts/dev-all.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${BACKEND_PID:-}" ]]; then
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "${WEB_PID:-}" ]]; then
|
||||
kill "$WEB_PID" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "Starting backend API..."
|
||||
(
|
||||
cd "$ROOT_DIR/backend"
|
||||
go run ./cmd/api
|
||||
) &
|
||||
BACKEND_PID=$!
|
||||
|
||||
echo "Starting Svelte web client..."
|
||||
(
|
||||
cd "$ROOT_DIR/apps/web"
|
||||
npm run dev
|
||||
) &
|
||||
WEB_PID=$!
|
||||
|
||||
wait -n "$BACKEND_PID" "$WEB_PID"
|
||||
|
||||
Reference in New Issue
Block a user