Compare commits

...

24 Commits

Author SHA1 Message Date
1fcec3e0b5 chore: remove sdd task reports 2026-07-19 20:52:56 +08:00
7b1bbf17f3 chore: remove last sdd scratch report 2026-07-19 20:51:20 +08:00
884148b88f fix web review validation and clipboard tests 2026-07-19 20:50:42 +08:00
ec59dee6b9 chore: remove final review scratch report 2026-07-19 20:37:02 +08:00
2d82705679 fix: resolve final workbench re-review findings 2026-07-19 20:36:29 +08:00
e289b1dbb0 chore: remove final sdd scratch artifact 2026-07-19 20:20:52 +08:00
27ff659c30 fix: resolve final workbench review findings 2026-07-19 20:20:09 +08:00
43d0b9f049 chore: remove sdd scratch artifact 2026-07-19 20:01:14 +08:00
ba4ca16375 fix: resolve workbench final review findings 2026-07-19 19:59:38 +08:00
6341a6bd8b test: cover project channel workbench flow 2026-07-19 19:37:05 +08:00
7086b8b0f4 fix: style active inspector view 2026-07-19 19:32:21 +08:00
092930e176 style: apply workbench visual system 2026-07-19 19:28:48 +08:00
1a82a8b53d fix: address workbench inspector review findings 2026-07-19 19:24:27 +08:00
3194cbed29 feat: add workbench channel templates 2026-07-19 19:18:36 +08:00
95dce81161 feat: add project channel workbench shell 2026-07-19 19:12:28 +08:00
6c12d4b0db feat: add project workspace data model 2026-07-19 19:06:42 +08:00
5b95668945 feat: add full workbench login page 2026-07-19 19:01:27 +08:00
4fc9271896 docs: plan discord style workbench ui 2026-07-19 18:55:08 +08:00
8f2b8c9585 docs: add ui design guide 2026-07-19 18:49:20 +08:00
d538627272 docs: design discord style project workbench 2026-07-19 18:44:21 +08:00
4e46fee809 add model identities and restore httpx router 2026-07-19 00:06:21 +08:00
f7cd69c70f add development and build scripts 2026-07-18 22:03:48 +08:00
7800b07d42 refactor backend packages and global db service 2026-07-18 21:58:15 +08:00
62bd3d0455 feat: load backend config from yaml 2026-07-18 21:01:34 +08:00
90 changed files with 4808 additions and 884 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@ node_modules/
dist/
target/
**/src-tauri/gen/
backend/etc/agent.local.yaml
.env
data/
coverage/

View File

@@ -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 阶段不要引入全局标签体系。

View File

@@ -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`

View File

@@ -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);
});

View File

@@ -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}

View File

@@ -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>

View 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();
});
});

View 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}

View 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');
});
});

View 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>

View 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.');
});
});

View 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>&#9881;</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>

View 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>

View 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>

View 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');
}
});
});

View 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>&#8592;</button>
<button aria-label="Forward" disabled>&#8594;</button>
<button aria-label={`Account ${account}`} disabled>{account.slice(0, 2).toUpperCase()}</button>
</div>
</header>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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();
});
});

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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?:\/\//),
});
});
});

View 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];
}

View 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[];
};

View File

@@ -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; }
}

View File

@@ -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)
}
}

View 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"

View File

@@ -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

View File

@@ -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=

View File

@@ -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
}

View File

@@ -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
}

View 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))
}

View File

@@ -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{})
}

View File

@@ -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{},
)
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
}

View 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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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:

View File

@@ -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
}

View 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
}

View File

@@ -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 {

View 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
}

View File

@@ -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
}

View 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(&notes).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
}

View 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"])
}

View File

@@ -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 {

View File

@@ -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(&note).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(&note).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
}

View 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"
}

View 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"
}

View 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"
}

View 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
}

View 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
}

View 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"
}

View 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{},
)
}

View 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"
}

View File

@@ -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)

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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(&notes).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
}

View File

@@ -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"])
}

View File

@@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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
View 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
View 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"