mirror of
https://github.com/willchen96/mike.git
synced 2026-06-08 20:25:13 +02:00
Merge branch 'main' into codex/safe-local-testing-guide
This commit is contained in:
commit
1f191fea59
40 changed files with 3061 additions and 862 deletions
|
|
@ -5,8 +5,8 @@ Open-source release containing the Mike frontend and backend.
|
|||
## Contents
|
||||
|
||||
- `frontend/` - Next.js application
|
||||
- `backend/` - Express API, Supabase access, document processing, and migrations
|
||||
- `backend/migrations/000_one_shot_schema.sql` - one-shot Supabase schema for fresh databases
|
||||
- `backend/` - Express API, Supabase access, document processing, and database schema
|
||||
- `backend/schema.sql` - Supabase schema for fresh databases
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -24,10 +24,7 @@ cp backend/.env.example backend/.env
|
|||
cp frontend/.env.local.example frontend/.env.local
|
||||
```
|
||||
|
||||
Before adding real secrets or uploading documents, read
|
||||
[`docs/safe-local-testing.md`](docs/safe-local-testing.md).
|
||||
|
||||
Run `backend/migrations/000_one_shot_schema.sql` in the Supabase SQL editor for a fresh database.
|
||||
Run `backend/schema.sql` in the Supabase SQL editor for a fresh database.
|
||||
|
||||
Start the backend:
|
||||
|
||||
|
|
|
|||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
|
|
@ -1,6 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
.env*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@
|
|||
"docx": "^9.5.0",
|
||||
"dotenv": "^17.4.1",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.7.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"libreoffice-convert": "^1.6.0",
|
||||
"mammoth": "^1.9.0",
|
||||
|
|
@ -471,6 +473,8 @@
|
|||
|
||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
|
||||
|
|
@ -517,6 +521,8 @@
|
|||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"helmet": ["helmet@8.1.0", "", {}, "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg=="],
|
||||
|
||||
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||
|
|
@ -533,6 +539,8 @@
|
|||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
|
|
|||
|
|
@ -1,340 +0,0 @@
|
|||
-- Mike one-shot Supabase schema
|
||||
-- Based on supabase-migration.sql plus the later backend/migrations/*.sql files.
|
||||
-- Use this for a fresh Supabase database. Existing deployments should continue
|
||||
-- to apply the incremental migration files instead.
|
||||
|
||||
create extension if not exists "pgcrypto";
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- User profiles
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.user_profiles (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null unique references auth.users(id) on delete cascade,
|
||||
display_name text,
|
||||
organisation text,
|
||||
tier text not null default 'Free',
|
||||
message_credits_used integer not null default 0,
|
||||
credits_reset_date timestamptz not null default (now() + interval '30 days'),
|
||||
tabular_model text not null default 'gemini-3-flash-preview',
|
||||
claude_api_key text,
|
||||
gemini_api_key text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_user_profiles_user
|
||||
on public.user_profiles(user_id);
|
||||
|
||||
alter table public.user_profiles enable row level security;
|
||||
|
||||
drop policy if exists "Users can view their own profile" on public.user_profiles;
|
||||
create policy "Users can view their own profile"
|
||||
on public.user_profiles for select
|
||||
using (auth.uid() = user_id);
|
||||
|
||||
drop policy if exists "Users can update their own profile" on public.user_profiles;
|
||||
create policy "Users can update their own profile"
|
||||
on public.user_profiles for update
|
||||
using (auth.uid() = user_id);
|
||||
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
insert into public.user_profiles (user_id)
|
||||
values (new.id)
|
||||
on conflict (user_id) do nothing;
|
||||
return new;
|
||||
exception when others then
|
||||
-- Never block signup if the profile insert fails.
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists on_auth_user_created on auth.users;
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute procedure public.handle_new_user();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Projects and documents
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.projects (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id text not null,
|
||||
name text not null,
|
||||
cm_number text,
|
||||
visibility text not null default 'private',
|
||||
shared_with jsonb not null default '[]'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_projects_user
|
||||
on public.projects(user_id);
|
||||
|
||||
create index if not exists projects_shared_with_idx
|
||||
on public.projects using gin (shared_with);
|
||||
|
||||
create table if not exists public.project_subfolders (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid not null references public.projects(id) on delete cascade,
|
||||
user_id text not null,
|
||||
name text not null,
|
||||
parent_folder_id uuid references public.project_subfolders(id) on delete cascade,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_project_subfolders_project
|
||||
on public.project_subfolders(project_id);
|
||||
|
||||
create table if not exists public.documents (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
user_id text not null,
|
||||
filename text not null,
|
||||
file_type text,
|
||||
size_bytes integer not null default 0,
|
||||
page_count integer,
|
||||
structure_tree jsonb,
|
||||
status text not null default 'pending',
|
||||
folder_id uuid references public.project_subfolders(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_documents_user_project
|
||||
on public.documents(user_id, project_id);
|
||||
|
||||
create index if not exists idx_documents_project_folder
|
||||
on public.documents(project_id, folder_id);
|
||||
|
||||
create table if not exists public.document_versions (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
document_id uuid not null references public.documents(id) on delete cascade,
|
||||
storage_path text not null,
|
||||
pdf_storage_path text,
|
||||
source text not null default 'upload',
|
||||
version_number integer,
|
||||
display_name text,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint document_versions_source_check
|
||||
check (source = any (array[
|
||||
'upload'::text,
|
||||
'user_upload'::text,
|
||||
'assistant_edit'::text,
|
||||
'user_accept'::text,
|
||||
'user_reject'::text,
|
||||
'generated'::text
|
||||
]))
|
||||
);
|
||||
|
||||
create index if not exists document_versions_document_id_idx
|
||||
on public.document_versions(document_id, created_at desc);
|
||||
|
||||
create index if not exists document_versions_doc_vnum_idx
|
||||
on public.document_versions(document_id, version_number);
|
||||
|
||||
alter table public.documents
|
||||
add column if not exists current_version_id uuid
|
||||
references public.document_versions(id) on delete set null;
|
||||
|
||||
create table if not exists public.document_edits (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
document_id uuid not null references public.documents(id) on delete cascade,
|
||||
chat_message_id uuid,
|
||||
version_id uuid not null references public.document_versions(id) on delete cascade,
|
||||
change_id text not null,
|
||||
del_w_id text,
|
||||
ins_w_id text,
|
||||
deleted_text text not null default '',
|
||||
inserted_text text not null default '',
|
||||
context_before text,
|
||||
context_after text,
|
||||
status text not null default 'pending'
|
||||
check (status = any (array[
|
||||
'pending'::text,
|
||||
'accepted'::text,
|
||||
'rejected'::text
|
||||
])),
|
||||
created_at timestamptz not null default now(),
|
||||
resolved_at timestamptz
|
||||
);
|
||||
|
||||
create index if not exists document_edits_document_id_idx
|
||||
on public.document_edits(document_id, created_at desc);
|
||||
|
||||
create index if not exists document_edits_message_id_idx
|
||||
on public.document_edits(chat_message_id);
|
||||
|
||||
create index if not exists document_edits_version_id_idx
|
||||
on public.document_edits(version_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Workflows
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.workflows (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id text,
|
||||
title text not null,
|
||||
type text not null,
|
||||
prompt_md text,
|
||||
columns_config jsonb,
|
||||
practice text,
|
||||
is_system boolean not null default false,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_workflows_user
|
||||
on public.workflows(user_id);
|
||||
|
||||
create table if not exists public.hidden_workflows (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id text not null,
|
||||
workflow_id text not null,
|
||||
created_at timestamptz not null default now(),
|
||||
unique(user_id, workflow_id)
|
||||
);
|
||||
|
||||
create index if not exists idx_hidden_workflows_user
|
||||
on public.hidden_workflows(user_id);
|
||||
|
||||
create table if not exists public.workflow_shares (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
workflow_id uuid not null references public.workflows(id) on delete cascade,
|
||||
shared_by_user_id text not null,
|
||||
shared_with_email text not null,
|
||||
allow_edit boolean not null default false,
|
||||
created_at timestamptz not null default now(),
|
||||
constraint workflow_shares_workflow_email_unique
|
||||
unique(workflow_id, shared_with_email)
|
||||
);
|
||||
|
||||
create index if not exists workflow_shares_workflow_id_idx
|
||||
on public.workflow_shares(workflow_id);
|
||||
|
||||
create index if not exists workflow_shares_email_idx
|
||||
on public.workflow_shares(shared_with_email);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Assistant chats
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.chats (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
user_id text not null,
|
||||
title text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_chats_user
|
||||
on public.chats(user_id);
|
||||
|
||||
create index if not exists idx_chats_project
|
||||
on public.chats(project_id);
|
||||
|
||||
create table if not exists public.chat_messages (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
chat_id uuid not null references public.chats(id) on delete cascade,
|
||||
role text not null,
|
||||
content jsonb,
|
||||
files jsonb,
|
||||
annotations jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_chat_messages_chat
|
||||
on public.chat_messages(chat_id);
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_constraint
|
||||
where conname = 'document_edits_chat_message_id_fkey'
|
||||
and conrelid = 'public.document_edits'::regclass
|
||||
) then
|
||||
alter table public.document_edits
|
||||
add constraint document_edits_chat_message_id_fkey
|
||||
foreign key (chat_message_id)
|
||||
references public.chat_messages(id)
|
||||
on delete set null;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tabular reviews
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
create table if not exists public.tabular_reviews (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
project_id uuid references public.projects(id) on delete cascade,
|
||||
user_id text not null,
|
||||
title text,
|
||||
columns_config jsonb,
|
||||
workflow_id uuid references public.workflows(id) on delete set null,
|
||||
practice text,
|
||||
shared_with jsonb not null default '[]'::jsonb,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_tabular_reviews_user
|
||||
on public.tabular_reviews(user_id);
|
||||
|
||||
create index if not exists idx_tabular_reviews_project
|
||||
on public.tabular_reviews(project_id);
|
||||
|
||||
create index if not exists tabular_reviews_shared_with_idx
|
||||
on public.tabular_reviews using gin (shared_with);
|
||||
|
||||
create table if not exists public.tabular_cells (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
review_id uuid not null references public.tabular_reviews(id) on delete cascade,
|
||||
document_id uuid not null references public.documents(id) on delete cascade,
|
||||
column_index integer not null,
|
||||
content text,
|
||||
citations jsonb,
|
||||
status text not null default 'pending',
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists idx_tabular_cells_review
|
||||
on public.tabular_cells(review_id, document_id, column_index);
|
||||
|
||||
create table if not exists public.tabular_review_chats (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
review_id uuid not null references public.tabular_reviews(id) on delete cascade,
|
||||
user_id text not null,
|
||||
title text,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists tabular_review_chats_review_idx
|
||||
on public.tabular_review_chats(review_id, updated_at desc);
|
||||
|
||||
create index if not exists tabular_review_chats_user_idx
|
||||
on public.tabular_review_chats(user_id);
|
||||
|
||||
create table if not exists public.tabular_review_chat_messages (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
chat_id uuid not null references public.tabular_review_chats(id) on delete cascade,
|
||||
role text not null,
|
||||
content jsonb,
|
||||
annotations jsonb,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
create index if not exists tabular_review_chat_messages_chat_idx
|
||||
on public.tabular_review_chat_messages(chat_id, created_at);
|
||||
39
backend/package-lock.json
generated
39
backend/package-lock.json
generated
|
|
@ -7,7 +7,6 @@
|
|||
"": {
|
||||
"name": "mike-backend",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.90.0",
|
||||
"@aws-sdk/client-s3": "^3.787.0",
|
||||
|
|
@ -18,8 +17,10 @@
|
|||
"docx": "^9.5.0",
|
||||
"dotenv": "^17.4.1",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.7.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"libreoffice-convert": "^1.6.0",
|
||||
"mammoth": "^1.9.0",
|
||||
|
|
@ -3351,6 +3352,24 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
|
|
@ -3650,6 +3669,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
|
|
@ -3774,6 +3802,15 @@
|
|||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
{
|
||||
"name": "mike-backend",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
|
|
@ -18,8 +17,10 @@
|
|||
"docx": "^9.5.0",
|
||||
"dotenv": "^17.4.1",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"fast-diff": "^1.3.0",
|
||||
"fast-xml-parser": "^5.7.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"libreoffice-convert": "^1.6.0",
|
||||
"mammoth": "^1.9.0",
|
||||
|
|
@ -35,5 +36,6 @@
|
|||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
|
|
|||
1046
backend/schema.sql
Normal file
1046
backend/schema.sql
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,8 @@
|
|||
import "dotenv/config";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { chatRouter } from "./routes/chat";
|
||||
import { projectsRouter } from "./routes/projects";
|
||||
import { projectChatRouter } from "./routes/projectChat";
|
||||
|
|
@ -12,6 +14,79 @@ import { downloadsRouter } from "./routes/downloads";
|
|||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT ?? 3001;
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function minutes(value: number): number {
|
||||
return value * 60 * 1000;
|
||||
}
|
||||
|
||||
function hours(value: number): number {
|
||||
return minutes(value * 60);
|
||||
}
|
||||
|
||||
function makeLimiter(options: {
|
||||
windowMs: number;
|
||||
max: number;
|
||||
message?: string;
|
||||
}) {
|
||||
return rateLimit({
|
||||
windowMs: options.windowMs,
|
||||
max: options.max,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => req.method === "OPTIONS",
|
||||
message: {
|
||||
detail:
|
||||
options.message ?? "Too many requests. Please try again later.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const generalLimiter = makeLimiter({
|
||||
windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)),
|
||||
max: envInt("RATE_LIMIT_GENERAL_MAX", 300),
|
||||
});
|
||||
|
||||
const chatLimiter = makeLimiter({
|
||||
windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)),
|
||||
max: envInt("RATE_LIMIT_CHAT_MAX", 30),
|
||||
message: "Too many chat requests. Please try again later.",
|
||||
});
|
||||
|
||||
const chatCreateLimiter = makeLimiter({
|
||||
windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)),
|
||||
max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60),
|
||||
});
|
||||
|
||||
const uploadLimiter = makeLimiter({
|
||||
windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)),
|
||||
max: envInt("RATE_LIMIT_UPLOAD_MAX", 50),
|
||||
message: "Too many upload requests. Please try again later.",
|
||||
});
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1));
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
hsts: isProduction
|
||||
? {
|
||||
maxAge: 15552000,
|
||||
includeSubDomains: true,
|
||||
}
|
||||
: false,
|
||||
referrerPolicy: { policy: "no-referrer" },
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
|
|
@ -20,8 +95,20 @@ app.use(
|
|||
}),
|
||||
);
|
||||
|
||||
app.use(generalLimiter);
|
||||
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
|
||||
app.post("/chat", chatLimiter);
|
||||
app.post("/projects/:projectId/chat", chatLimiter);
|
||||
app.post("/tabular-review/:reviewId/chat", chatLimiter);
|
||||
app.post("/tabular-review/:reviewId/generate", chatLimiter);
|
||||
app.post("/chat/create", chatCreateLimiter);
|
||||
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
|
||||
app.post("/single-documents", uploadLimiter);
|
||||
app.post("/single-documents/:documentId/versions", uploadLimiter);
|
||||
app.post("/projects/:projectId/documents", uploadLimiter);
|
||||
|
||||
app.use("/chat", chatRouter);
|
||||
app.use("/projects", projectsRouter);
|
||||
app.use("/projects/:projectId/chat", projectChatRouter);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import {
|
|||
type EditInput,
|
||||
} from "./docxTrackedChanges";
|
||||
import { buildDownloadUrl } from "./downloadTokens";
|
||||
import { attachActiveVersionPaths, loadActiveVersion } from "./documentVersions";
|
||||
import {
|
||||
attachActiveVersionPaths,
|
||||
loadActiveVersion,
|
||||
} from "./documentVersions";
|
||||
import {
|
||||
streamChatWithTools,
|
||||
resolveModel,
|
||||
|
|
@ -56,7 +59,10 @@ export type TabularCellStore = {
|
|||
columns: { index: number; name: string }[];
|
||||
documents: { id: string; filename: string }[];
|
||||
/** key: `${colIndex}:${docId}` */
|
||||
cells: Map<string, { summary: string; flag?: string; reasoning?: string } | null>;
|
||||
cells: Map<
|
||||
string,
|
||||
{ summary: string; flag?: string; reasoning?: string } | null
|
||||
>;
|
||||
};
|
||||
|
||||
export type ToolCall = {
|
||||
|
|
@ -320,25 +326,43 @@ export const TOOLS = [
|
|||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
description: "Document title (used as filename and heading)",
|
||||
description:
|
||||
"Document title (used as filename and heading)",
|
||||
},
|
||||
landscape: {
|
||||
type: "boolean",
|
||||
description: "Set to true for landscape page orientation. Default is portrait.",
|
||||
description:
|
||||
"Set to true for landscape page orientation. Default is portrait.",
|
||||
},
|
||||
sections: {
|
||||
type: "array",
|
||||
description: "List of document sections. Each section may contain a heading, prose content, or a table.",
|
||||
description:
|
||||
"List of document sections. Each section may contain a heading, prose content, or a table.",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
heading: { type: "string", description: "Optional section heading" },
|
||||
level: { type: "integer", description: "Heading level: 1, 2, or 3" },
|
||||
content: { type: "string", description: "Prose text content (paragraphs separated by double newlines)" },
|
||||
pageBreak: { type: "boolean", description: "Set to true to start this section on a new page. Use for contract signature pages." },
|
||||
heading: {
|
||||
type: "string",
|
||||
description: "Optional section heading",
|
||||
},
|
||||
level: {
|
||||
type: "integer",
|
||||
description: "Heading level: 1, 2, or 3",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description:
|
||||
"Prose text content (paragraphs separated by double newlines)",
|
||||
},
|
||||
pageBreak: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Set to true to start this section on a new page. Use for contract signature pages.",
|
||||
},
|
||||
table: {
|
||||
type: "object",
|
||||
description: "Optional table to render in this section",
|
||||
description:
|
||||
"Optional table to render in this section",
|
||||
properties: {
|
||||
headers: {
|
||||
type: "array",
|
||||
|
|
@ -351,7 +375,8 @@ export const TOOLS = [
|
|||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
description: "Array of rows, each row is an array of cell strings matching the headers order",
|
||||
description:
|
||||
"Array of rows, each row is an array of cell strings matching the headers order",
|
||||
},
|
||||
},
|
||||
required: ["headers", "rows"],
|
||||
|
|
@ -390,22 +415,31 @@ export const TOOLS = [
|
|||
},
|
||||
replace: {
|
||||
type: "string",
|
||||
description: "Replacement text. Empty string = pure deletion.",
|
||||
description:
|
||||
"Replacement text. Empty string = pure deletion.",
|
||||
},
|
||||
context_before: {
|
||||
type: "string",
|
||||
description: "~40 chars immediately preceding `find`, used to disambiguate.",
|
||||
description:
|
||||
"~40 chars immediately preceding `find`, used to disambiguate.",
|
||||
},
|
||||
context_after: {
|
||||
type: "string",
|
||||
description: "~40 chars immediately following `find`.",
|
||||
description:
|
||||
"~40 chars immediately following `find`.",
|
||||
},
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "Short explanation shown to the user on the card.",
|
||||
description:
|
||||
"Short explanation shown to the user on the card.",
|
||||
},
|
||||
},
|
||||
required: ["find", "replace", "context_before", "context_after"],
|
||||
required: [
|
||||
"find",
|
||||
"replace",
|
||||
"context_before",
|
||||
"context_after",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -578,7 +612,11 @@ export async function enrichWithPriorEvents(
|
|||
|
||||
export function buildMessages(
|
||||
messages: ChatMessage[],
|
||||
docAvailability: { doc_id: string; filename: string; folder_path?: string }[],
|
||||
docAvailability: {
|
||||
doc_id: string;
|
||||
filename: string;
|
||||
folder_path?: string;
|
||||
}[],
|
||||
systemPromptExtra?: string,
|
||||
docIndex?: DocIndex,
|
||||
) {
|
||||
|
|
@ -592,7 +630,9 @@ export function buildMessages(
|
|||
if (docAvailability.length) {
|
||||
systemContent += "\n\n---\nAVAILABLE DOCUMENTS:\n";
|
||||
for (const doc of docAvailability) {
|
||||
const label = doc.folder_path ? `${doc.folder_path} / ${doc.filename}` : doc.filename;
|
||||
const label = doc.folder_path
|
||||
? `${doc.folder_path} / ${doc.filename}`
|
||||
: doc.filename;
|
||||
systemContent += `- ${doc.doc_id}: ${label}\n`;
|
||||
}
|
||||
systemContent +=
|
||||
|
|
@ -620,9 +660,7 @@ export function buildMessages(
|
|||
const slug = f.document_id
|
||||
? slugByDocumentId.get(f.document_id)
|
||||
: undefined;
|
||||
return slug
|
||||
? `- ${slug}: ${f.filename}`
|
||||
: `- ${f.filename}`;
|
||||
return slug ? `- ${slug}: ${f.filename}` : `- ${f.filename}`;
|
||||
});
|
||||
content = `[The user attached the following document(s) to this message:\n${lines.join("\n")}]\n\n${content}`;
|
||||
}
|
||||
|
|
@ -676,30 +714,50 @@ export async function generateDocx(
|
|||
) {
|
||||
try {
|
||||
const {
|
||||
Document, Paragraph, HeadingLevel, Packer,
|
||||
Table, TableRow, TableCell, WidthType, BorderStyle,
|
||||
TextRun, AlignmentType, PageOrientation, PageBreak,
|
||||
Document,
|
||||
Paragraph,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
WidthType,
|
||||
BorderStyle,
|
||||
TextRun,
|
||||
AlignmentType,
|
||||
PageOrientation,
|
||||
PageBreak,
|
||||
} = await import("docx");
|
||||
|
||||
const FONT = "Times New Roman";
|
||||
const SIZE = 22; // 11pt in half-points
|
||||
|
||||
type DocChild = InstanceType<typeof Paragraph> | InstanceType<typeof Table>;
|
||||
type DocChild =
|
||||
| InstanceType<typeof Paragraph>
|
||||
| InstanceType<typeof Table>;
|
||||
const children: DocChild[] = [];
|
||||
children.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.TITLE,
|
||||
spacing: { after: 200 },
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: title.toUpperCase(), color: "000000", font: FONT, size: SIZE, bold: true })],
|
||||
children: [
|
||||
new TextRun({
|
||||
text: title.toUpperCase(),
|
||||
color: "000000",
|
||||
font: FONT,
|
||||
size: SIZE,
|
||||
bold: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const cellBorder = {
|
||||
top: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
||||
top: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
||||
left: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
||||
right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
||||
left: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
||||
right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
|
||||
};
|
||||
|
||||
const headingLevels = [
|
||||
|
|
@ -718,9 +776,7 @@ export async function generateDocx(
|
|||
table?: { headers: string[]; rows: string[][] };
|
||||
}[]) {
|
||||
if (section.pageBreak) {
|
||||
children.push(
|
||||
new Paragraph({ children: [new PageBreak()] }),
|
||||
);
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
}
|
||||
if (section.heading) {
|
||||
const idx = Math.min((section.level ?? 1) - 1, 3);
|
||||
|
|
@ -732,7 +788,15 @@ export async function generateDocx(
|
|||
new Paragraph({
|
||||
heading: headingLevels[idx],
|
||||
spacing: { after: 160 },
|
||||
children: [new TextRun({ text: headingText, color: "000000", font: FONT, size: SIZE, bold: true })],
|
||||
children: [
|
||||
new TextRun({
|
||||
text: headingText,
|
||||
color: "000000",
|
||||
font: FONT,
|
||||
size: SIZE,
|
||||
bold: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -751,7 +815,14 @@ export async function generateDocx(
|
|||
shading: { fill: "F2F2F2" },
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: h, bold: true, font: FONT, size: SIZE })],
|
||||
children: [
|
||||
new TextRun({
|
||||
text: h,
|
||||
bold: true,
|
||||
font: FONT,
|
||||
size: SIZE,
|
||||
}),
|
||||
],
|
||||
alignment: AlignmentType.LEFT,
|
||||
}),
|
||||
],
|
||||
|
|
@ -784,7 +855,13 @@ export async function generateDocx(
|
|||
borders: cellBorder,
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: cell, font: FONT, size: SIZE })],
|
||||
children: [
|
||||
new TextRun({
|
||||
text: cell,
|
||||
font: FONT,
|
||||
size: SIZE,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
|
@ -810,14 +887,26 @@ export async function generateDocx(
|
|||
new Paragraph({
|
||||
bullet: { level: 0 },
|
||||
spacing: { after: 120 },
|
||||
children: [new TextRun({ text: bulletMatch[1], font: FONT, size: SIZE })],
|
||||
children: [
|
||||
new TextRun({
|
||||
text: bulletMatch[1],
|
||||
font: FONT,
|
||||
size: SIZE,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 120 },
|
||||
children: [new TextRun({ text: trimmed, font: FONT, size: SIZE })],
|
||||
children: [
|
||||
new TextRun({
|
||||
text: trimmed,
|
||||
font: FONT,
|
||||
size: SIZE,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -829,7 +918,9 @@ export async function generateDocx(
|
|||
? { page: { size: { orientation: PageOrientation.LANDSCAPE } } }
|
||||
: {};
|
||||
|
||||
const doc = new Document({ sections: [{ properties: pageSetup, children }] });
|
||||
const doc = new Document({
|
||||
sections: [{ properties: pageSetup, children }],
|
||||
});
|
||||
const buf = await Packer.toBuffer(doc);
|
||||
const docId = crypto.randomUUID().replace(/-/g, "");
|
||||
const safeTitle =
|
||||
|
|
@ -973,11 +1064,11 @@ export async function runEditDocument(params: {
|
|||
const current = await loadCurrentVersionBytes(documentId, db);
|
||||
if (!current) return { ok: false, error: "Could not load document bytes." };
|
||||
|
||||
const { bytes: editedBytes, changes, errors } = await applyTrackedEdits(
|
||||
current.bytes,
|
||||
edits,
|
||||
{ author: "Mike" },
|
||||
);
|
||||
const {
|
||||
bytes: editedBytes,
|
||||
changes,
|
||||
errors,
|
||||
} = await applyTrackedEdits(current.bytes, edits, { author: "Mike" });
|
||||
|
||||
if (changes.length === 0) {
|
||||
return {
|
||||
|
|
@ -1028,7 +1119,8 @@ export async function runEditDocument(params: {
|
|||
.order("version_number", { ascending: false, nullsFirst: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
nextVersionNumber = ((maxRow?.version_number as number | null) ?? 1) + 1;
|
||||
nextVersionNumber =
|
||||
((maxRow?.version_number as number | null) ?? 1) + 1;
|
||||
|
||||
// Inherit the display name from the most recent prior version so
|
||||
// user-applied renames carry forward through further edits. Falls
|
||||
|
|
@ -1081,7 +1173,9 @@ export async function runEditDocument(params: {
|
|||
const { data: insertedEdits, error: editsErr } = await db
|
||||
.from("document_edits")
|
||||
.insert(editRows)
|
||||
.select("id, change_id, del_w_id, ins_w_id, deleted_text, inserted_text, context_before, context_after");
|
||||
.select(
|
||||
"id, change_id, del_w_id, ins_w_id, deleted_text, inserted_text, context_before, context_after",
|
||||
);
|
||||
|
||||
if (editsErr || !insertedEdits) {
|
||||
return { ok: false, error: "Failed to record edits." };
|
||||
|
|
@ -1092,25 +1186,34 @@ export async function runEditDocument(params: {
|
|||
.update({ current_version_id: versionRowId })
|
||||
.eq("id", documentId);
|
||||
|
||||
const annotations: EditAnnotation[] = insertedEdits.map((r: { id: string; change_id: string; deleted_text: string; inserted_text: string; context_before: string | null; context_after: string | null }) => {
|
||||
const src = changes.find((c) => c.id === r.change_id);
|
||||
return {
|
||||
kind: "edit",
|
||||
edit_id: r.id,
|
||||
document_id: documentId,
|
||||
version_id: versionRowId,
|
||||
version_number: nextVersionNumber,
|
||||
change_id: r.change_id,
|
||||
del_w_id: src?.delId,
|
||||
ins_w_id: src?.insId,
|
||||
deleted_text: r.deleted_text ?? "",
|
||||
inserted_text: r.inserted_text ?? "",
|
||||
context_before: r.context_before ?? "",
|
||||
context_after: r.context_after ?? "",
|
||||
reason: src?.reason,
|
||||
status: "pending",
|
||||
};
|
||||
});
|
||||
const annotations: EditAnnotation[] = insertedEdits.map(
|
||||
(r: {
|
||||
id: string;
|
||||
change_id: string;
|
||||
deleted_text: string;
|
||||
inserted_text: string;
|
||||
context_before: string | null;
|
||||
context_after: string | null;
|
||||
}) => {
|
||||
const src = changes.find((c) => c.id === r.change_id);
|
||||
return {
|
||||
kind: "edit",
|
||||
edit_id: r.id,
|
||||
document_id: documentId,
|
||||
version_id: versionRowId,
|
||||
version_number: nextVersionNumber,
|
||||
change_id: r.change_id,
|
||||
del_w_id: src?.delId,
|
||||
ins_w_id: src?.insId,
|
||||
deleted_text: r.deleted_text ?? "",
|
||||
inserted_text: r.inserted_text ?? "",
|
||||
context_before: r.context_before ?? "",
|
||||
context_after: r.context_after ?? "",
|
||||
reason: src?.reason,
|
||||
status: "pending",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// Persistent, non-expiring permalink. The backend streams fresh bytes
|
||||
// on each request, so this URL stays valid as long as the file exists.
|
||||
|
|
@ -1216,9 +1319,7 @@ async function readDocumentContent(
|
|||
{
|
||||
const head = Buffer.from(raw).subarray(0, 8);
|
||||
const hex = head.toString("hex");
|
||||
const ascii = head
|
||||
.toString("binary")
|
||||
.replace(/[^\x20-\x7e]/g, ".");
|
||||
const ascii = head.toString("binary").replace(/[^\x20-\x7e]/g, ".");
|
||||
console.log(
|
||||
`[read_document] magic bytes hex=${hex} ascii="${ascii}" for filename="${docInfo.filename}"`,
|
||||
);
|
||||
|
|
@ -1273,7 +1374,9 @@ async function readDocumentContent(
|
|||
err,
|
||||
);
|
||||
if (emitEvents)
|
||||
write(`data: ${JSON.stringify({ type: "doc_read", filename: docInfo.filename })}\n\n`);
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "doc_read", filename: docInfo.filename })}\n\n`,
|
||||
);
|
||||
return "Document could not be read.";
|
||||
}
|
||||
}
|
||||
|
|
@ -1385,7 +1488,10 @@ async function findInDocumentContent(params: {
|
|||
const { norm, origIdx } = normalizeWithMap(text);
|
||||
const needle = normalizeQuery(query);
|
||||
if (!needle) {
|
||||
return JSON.stringify({ ok: false, error: "Empty query after normalization." });
|
||||
return JSON.stringify({
|
||||
ok: false,
|
||||
error: "Empty query after normalization.",
|
||||
});
|
||||
}
|
||||
|
||||
type Hit = {
|
||||
|
|
@ -1528,19 +1634,30 @@ export async function runToolCalls(
|
|||
const rawDocId = args.doc_id as string;
|
||||
const docId =
|
||||
resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId;
|
||||
const content = await readDocumentContent(docId, docStore, write, docIndex, db);
|
||||
const content = await readDocumentContent(
|
||||
docId,
|
||||
docStore,
|
||||
write,
|
||||
docIndex,
|
||||
db,
|
||||
);
|
||||
const filename = docStore.get(docId)?.filename;
|
||||
const documentId = docIndex?.[docId]?.document_id;
|
||||
if (filename) docsRead.push({ filename, document_id: documentId });
|
||||
toolResults.push({ role: "tool", tool_call_id: tc.id, content });
|
||||
|
||||
} else if (tc.function.name === "find_in_document") {
|
||||
const rawDocId = args.doc_id as string;
|
||||
const docId =
|
||||
resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId;
|
||||
const query = (args.query as string) ?? "";
|
||||
const maxResults = typeof args.max_results === "number" ? args.max_results : undefined;
|
||||
const contextChars = typeof args.context_chars === "number" ? args.context_chars : undefined;
|
||||
const maxResults =
|
||||
typeof args.max_results === "number"
|
||||
? args.max_results
|
||||
: undefined;
|
||||
const contextChars =
|
||||
typeof args.context_chars === "number"
|
||||
? args.context_chars
|
||||
: undefined;
|
||||
const content = await findInDocumentContent({
|
||||
docLabel: docId,
|
||||
query,
|
||||
|
|
@ -1569,7 +1686,6 @@ export async function runToolCalls(
|
|||
});
|
||||
}
|
||||
toolResults.push({ role: "tool", tool_call_id: tc.id, content });
|
||||
|
||||
} else if (tc.function.name === "list_documents") {
|
||||
const list = Array.from(docStore.entries()).map(
|
||||
([doc_id, info]) => ({
|
||||
|
|
@ -1583,7 +1699,6 @@ export async function runToolCalls(
|
|||
tool_call_id: tc.id,
|
||||
content: JSON.stringify(list),
|
||||
});
|
||||
|
||||
} else if (tc.function.name === "fetch_documents") {
|
||||
const rawDocIds = (args.doc_ids as string[]) ?? [];
|
||||
const docIds = rawDocIds.map(
|
||||
|
|
@ -1591,7 +1706,13 @@ export async function runToolCalls(
|
|||
);
|
||||
const parts: string[] = [];
|
||||
for (const docId of docIds) {
|
||||
const content = await readDocumentContent(docId, docStore, write, docIndex, db);
|
||||
const content = await readDocumentContent(
|
||||
docId,
|
||||
docStore,
|
||||
write,
|
||||
docIndex,
|
||||
db,
|
||||
);
|
||||
const filename = docStore.get(docId)?.filename ?? docId;
|
||||
parts.push(`--- ${filename} (${docId}) ---\n${content}`);
|
||||
if (docStore.get(docId)) {
|
||||
|
|
@ -1604,18 +1725,25 @@ export async function runToolCalls(
|
|||
tool_call_id: tc.id,
|
||||
content: parts.join("\n\n"),
|
||||
});
|
||||
|
||||
} else if (tc.function.name === "list_workflows") {
|
||||
const list = workflowStore
|
||||
? Array.from(workflowStore.entries()).map(([id, w]) => ({ id, title: w.title }))
|
||||
? Array.from(workflowStore.entries()).map(([id, w]) => ({
|
||||
id,
|
||||
title: w.title,
|
||||
}))
|
||||
: [];
|
||||
toolResults.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify(list) });
|
||||
|
||||
toolResults.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: JSON.stringify(list),
|
||||
});
|
||||
} else if (tc.function.name === "read_workflow") {
|
||||
const wfId = args.workflow_id as string;
|
||||
const wf = workflowStore?.get(wfId);
|
||||
if (wf) {
|
||||
write(`data: ${JSON.stringify({ type: "workflow_applied", workflow_id: wfId, title: wf.title })}\n\n`);
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "workflow_applied", workflow_id: wfId, title: wf.title })}\n\n`,
|
||||
);
|
||||
workflowsApplied.push({ workflow_id: wfId, title: wf.title });
|
||||
}
|
||||
toolResults.push({
|
||||
|
|
@ -1623,7 +1751,6 @@ export async function runToolCalls(
|
|||
tool_call_id: tc.id,
|
||||
content: wf ? wf.prompt_md : `Workflow '${wfId}' not found.`,
|
||||
});
|
||||
|
||||
} else if (tc.function.name === "read_table_cells" && tabularStore) {
|
||||
const colIndices = args.col_indices as number[] | undefined;
|
||||
const rowIndices = args.row_indices as number[] | undefined;
|
||||
|
|
@ -1632,23 +1759,36 @@ export async function runToolCalls(
|
|||
? tabularStore.columns.filter((_, i) => colIndices.includes(i))
|
||||
: tabularStore.columns;
|
||||
const filteredDocs = rowIndices?.length
|
||||
? tabularStore.documents.filter((_, i) => rowIndices.includes(i))
|
||||
? tabularStore.documents.filter((_, i) =>
|
||||
rowIndices.includes(i),
|
||||
)
|
||||
: tabularStore.documents;
|
||||
|
||||
const label = `${filteredCols.length} ${filteredCols.length === 1 ? "column" : "columns"} × ${filteredDocs.length} ${filteredDocs.length === 1 ? "row" : "rows"}`;
|
||||
write(`data: ${JSON.stringify({ type: "doc_read_start", filename: label })}\n\n`);
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "doc_read_start", filename: label })}\n\n`,
|
||||
);
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const col of filteredCols) {
|
||||
const colPos = tabularStore.columns.findIndex((c) => c.index === col.index);
|
||||
const colPos = tabularStore.columns.findIndex(
|
||||
(c) => c.index === col.index,
|
||||
);
|
||||
for (const doc of filteredDocs) {
|
||||
const rowPos = tabularStore.documents.findIndex((d) => d.id === doc.id);
|
||||
const cell = tabularStore.cells.get(`${col.index}:${doc.id}`);
|
||||
lines.push(`[COL:${colPos} "${col.name}" | ROW:${rowPos} "${doc.filename}"]`);
|
||||
const rowPos = tabularStore.documents.findIndex(
|
||||
(d) => d.id === doc.id,
|
||||
);
|
||||
const cell = tabularStore.cells.get(
|
||||
`${col.index}:${doc.id}`,
|
||||
);
|
||||
lines.push(
|
||||
`[COL:${colPos} "${col.name}" | ROW:${rowPos} "${doc.filename}"]`,
|
||||
);
|
||||
if (cell?.summary) {
|
||||
lines.push(`Summary: ${cell.summary}`);
|
||||
if (cell.flag) lines.push(`Flag: ${cell.flag}`);
|
||||
if (cell.reasoning) lines.push(`Reasoning: ${cell.reasoning}`);
|
||||
if (cell.reasoning)
|
||||
lines.push(`Reasoning: ${cell.reasoning}`);
|
||||
} else {
|
||||
lines.push(`(not yet generated)`);
|
||||
}
|
||||
|
|
@ -1656,14 +1796,15 @@ export async function runToolCalls(
|
|||
}
|
||||
}
|
||||
|
||||
write(`data: ${JSON.stringify({ type: "doc_read", filename: label })}\n\n`);
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "doc_read", filename: label })}\n\n`,
|
||||
);
|
||||
docsRead.push({ filename: label });
|
||||
toolResults.push({
|
||||
role: "tool",
|
||||
tool_call_id: tc.id,
|
||||
content: lines.join("\n") || "No cells found.",
|
||||
});
|
||||
|
||||
} else if (tc.function.name === "edit_document" && docIndex) {
|
||||
const rawDocId = args.doc_id as string;
|
||||
const editsRaw = args.edits as unknown[] | undefined;
|
||||
|
|
@ -1707,10 +1848,7 @@ export async function runToolCalls(
|
|||
tool_call_id: tc.id,
|
||||
content: JSON.stringify({ error: err }),
|
||||
});
|
||||
} else if (
|
||||
!Array.isArray(editsRaw) ||
|
||||
editsRaw.length === 0
|
||||
) {
|
||||
} else if (!Array.isArray(editsRaw) || editsRaw.length === 0) {
|
||||
const err = "edits array is required and must not be empty.";
|
||||
emitEditError(docInfo.filename, indexed.document_id, err);
|
||||
toolResults.push({
|
||||
|
|
@ -1733,15 +1871,15 @@ export async function runToolCalls(
|
|||
filename: docInfo.filename,
|
||||
})}\n\n`,
|
||||
);
|
||||
const edits: EditInput[] = (editsRaw as Record<string, unknown>[]).map(
|
||||
(e) => ({
|
||||
find: String(e.find ?? ""),
|
||||
replace: String(e.replace ?? ""),
|
||||
context_before: String(e.context_before ?? ""),
|
||||
context_after: String(e.context_after ?? ""),
|
||||
reason: e.reason ? String(e.reason) : undefined,
|
||||
}),
|
||||
);
|
||||
const edits: EditInput[] = (
|
||||
editsRaw as Record<string, unknown>[]
|
||||
).map((e) => ({
|
||||
find: String(e.find ?? ""),
|
||||
replace: String(e.replace ?? ""),
|
||||
context_before: String(e.context_before ?? ""),
|
||||
context_after: String(e.context_after ?? ""),
|
||||
reason: e.reason ? String(e.reason) : undefined,
|
||||
}));
|
||||
const reuseVersion = turnEditState?.get(indexed.document_id);
|
||||
const result = await runEditDocument({
|
||||
documentId: indexed.document_id,
|
||||
|
|
@ -1824,7 +1962,6 @@ export async function runToolCalls(
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
} else if (tc.function.name === "replicate_document" && docIndex) {
|
||||
const rawDocId = args.doc_id as string;
|
||||
const requestedFilename =
|
||||
|
|
@ -1933,7 +2070,11 @@ export async function runToolCalls(
|
|||
.from("documents")
|
||||
.insert(docRows)
|
||||
.select("id, filename");
|
||||
if (docErr || !insertedDocs || insertedDocs.length === 0) {
|
||||
if (
|
||||
docErr ||
|
||||
!insertedDocs ||
|
||||
insertedDocs.length === 0
|
||||
) {
|
||||
fail(
|
||||
`Failed to record replicated documents: ${docErr?.message ?? "unknown"}`,
|
||||
);
|
||||
|
|
@ -2008,7 +2149,10 @@ export async function runToolCalls(
|
|||
`Failed to record replicated document versions: ${verErr?.message ?? "unknown"}`,
|
||||
);
|
||||
} else {
|
||||
const versionByDocId = new Map<string, string>();
|
||||
const versionByDocId = new Map<
|
||||
string,
|
||||
string
|
||||
>();
|
||||
for (const v of insertedVersions as {
|
||||
id: string;
|
||||
document_id: string;
|
||||
|
|
@ -2119,13 +2263,21 @@ export async function runToolCalls(
|
|||
fail(`replicate_document failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
} else if (tc.function.name === "generate_docx") {
|
||||
const title = args.title as string;
|
||||
const landscape = !!(args.landscape);
|
||||
console.log(`[generate_docx] title="${title}" landscape=${landscape} args.landscape=${args.landscape}`);
|
||||
const previewFilename = `${(title.replace(/[^a-zA-Z0-9 _-]/g, "").trim().slice(0, 64) || "document")}.docx`;
|
||||
write(`data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`);
|
||||
const landscape = !!args.landscape;
|
||||
console.log(
|
||||
`[generate_docx] title="${title}" landscape=${landscape} args.landscape=${args.landscape}`,
|
||||
);
|
||||
const previewFilename = `${
|
||||
title
|
||||
.replace(/[^a-zA-Z0-9 _-]/g, "")
|
||||
.trim()
|
||||
.slice(0, 64) || "document"
|
||||
}.docx`;
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`,
|
||||
);
|
||||
const result = await generateDocx(
|
||||
title,
|
||||
args.sections as unknown[],
|
||||
|
|
@ -2137,10 +2289,15 @@ export async function runToolCalls(
|
|||
if ("filename" in result && "download_url" in result) {
|
||||
const dlFilename = result.filename as string;
|
||||
const dlUrl = result.download_url as string;
|
||||
const documentId = (result as { document_id?: string }).document_id;
|
||||
const versionId = (result as { version_id?: string }).version_id;
|
||||
const versionNumber = (result as { version_number?: number }).version_number ?? null;
|
||||
const storagePath = (result as { storage_path?: string }).storage_path;
|
||||
const documentId = (result as { document_id?: string })
|
||||
.document_id;
|
||||
const versionId = (result as { version_id?: string })
|
||||
.version_id;
|
||||
const versionNumber =
|
||||
(result as { version_number?: number }).version_number ??
|
||||
null;
|
||||
const storagePath = (result as { storage_path?: string })
|
||||
.storage_path;
|
||||
|
||||
// Register the generated doc in the chat context so
|
||||
// edit_document (and read_document / find_in_document)
|
||||
|
|
@ -2181,14 +2338,19 @@ export async function runToolCalls(
|
|||
version_number: versionNumber,
|
||||
});
|
||||
} else {
|
||||
write(`data: ${JSON.stringify({ type: "doc_created", filename: previewFilename, download_url: "" })}\n\n`);
|
||||
write(
|
||||
`data: ${JSON.stringify({ type: "doc_created", filename: previewFilename, download_url: "" })}\n\n`,
|
||||
);
|
||||
}
|
||||
// Surface the chat-local doc label in the tool result so the
|
||||
// model can pass it as `doc_id` to edit_document / read_document
|
||||
// / find_in_document in the same turn. Without this the model
|
||||
// only sees the DB UUID, which isn't valid as a doc_id anchor.
|
||||
const toolResultPayload = newDocLabel
|
||||
? { ...(result as Record<string, unknown>), doc_id: newDocLabel }
|
||||
? {
|
||||
...(result as Record<string, unknown>),
|
||||
doc_id: newDocLabel,
|
||||
}
|
||||
: result;
|
||||
toolResults.push({
|
||||
role: "tool",
|
||||
|
|
@ -2313,7 +2475,21 @@ export async function runLLMStream(params: {
|
|||
*/
|
||||
projectId?: string | null;
|
||||
}): Promise<{ fullText: string; events: AssistantEvent[] }> {
|
||||
const { apiMessages, docStore, docIndex, userId, db, write, extraTools, workflowStore, tabularStore, buildCitations, model, apiKeys, projectId } = params;
|
||||
const {
|
||||
apiMessages,
|
||||
docStore,
|
||||
docIndex,
|
||||
userId,
|
||||
db,
|
||||
write,
|
||||
extraTools,
|
||||
workflowStore,
|
||||
tabularStore,
|
||||
buildCitations,
|
||||
model,
|
||||
apiKeys,
|
||||
projectId,
|
||||
} = params;
|
||||
const activeTools = extraTools?.length
|
||||
? [...TOOLS, ...WORKFLOW_TOOLS, ...extraTools]
|
||||
: [...TOOLS, ...WORKFLOW_TOOLS];
|
||||
|
|
@ -2323,14 +2499,6 @@ export async function runLLMStream(params: {
|
|||
const rawMsgs = apiMessages as { role: string; content: string | null }[];
|
||||
const systemPrompt =
|
||||
rawMsgs[0]?.role === "system" ? (rawMsgs[0].content ?? "") : "";
|
||||
console.log(
|
||||
"[runLLMStream] system prompt:\n" +
|
||||
"─".repeat(80) +
|
||||
"\n" +
|
||||
systemPrompt +
|
||||
"\n" +
|
||||
"─".repeat(80),
|
||||
);
|
||||
const chatMessages: LlmMessage[] = rawMsgs
|
||||
.filter((m) => m.role !== "system")
|
||||
.map((m) => ({
|
||||
|
|
@ -2473,17 +2641,17 @@ export async function runLLMStream(params: {
|
|||
workflowsApplied,
|
||||
docsEdited,
|
||||
} = await runToolCalls(
|
||||
toolCalls,
|
||||
docStore,
|
||||
userId,
|
||||
db,
|
||||
write,
|
||||
workflowStore,
|
||||
tabularStore,
|
||||
docIndex,
|
||||
turnEditState,
|
||||
projectId,
|
||||
);
|
||||
toolCalls,
|
||||
docStore,
|
||||
userId,
|
||||
db,
|
||||
write,
|
||||
workflowStore,
|
||||
tabularStore,
|
||||
docIndex,
|
||||
turnEditState,
|
||||
projectId,
|
||||
);
|
||||
for (const r of docsRead) {
|
||||
events.push({
|
||||
type: "doc_read",
|
||||
|
|
@ -2589,7 +2757,7 @@ export async function runLLMStream(params: {
|
|||
export function extractAnnotations(
|
||||
fullText: string,
|
||||
docIndex: DocIndex,
|
||||
events?: { type: string } & Record<string, unknown>[] | unknown[],
|
||||
events?: ({ type: string } & Record<string, unknown>[]) | unknown[],
|
||||
): unknown[] {
|
||||
const out: unknown[] = parseCitations(fullText).map((c) => {
|
||||
const docInfo = resolveDoc(c.doc_id, docIndex);
|
||||
|
|
@ -2606,9 +2774,13 @@ export function extractAnnotations(
|
|||
};
|
||||
});
|
||||
if (Array.isArray(events)) {
|
||||
for (const ev of events as { type?: string; annotations?: EditAnnotation[] }[]) {
|
||||
for (const ev of events as {
|
||||
type?: string;
|
||||
annotations?: EditAnnotation[];
|
||||
}[]) {
|
||||
if (ev?.type === "doc_edited" && Array.isArray(ev.annotations)) {
|
||||
for (const a of ev.annotations) out.push({ ...a, type: "edit_data" });
|
||||
for (const a of ev.annotations)
|
||||
out.push({ ...a, type: "edit_data" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2652,8 +2824,7 @@ export async function buildDocContext(
|
|||
if (!Array.isArray(content)) continue;
|
||||
for (const ev of content as Record<string, unknown>[]) {
|
||||
if (
|
||||
(ev?.type === "doc_created" ||
|
||||
ev?.type === "doc_edited") &&
|
||||
(ev?.type === "doc_created" || ev?.type === "doc_edited") &&
|
||||
typeof ev.document_id === "string"
|
||||
) {
|
||||
documentIds.add(ev.document_id);
|
||||
|
|
@ -2713,17 +2884,25 @@ export async function buildProjectDocContext(
|
|||
projectId: string,
|
||||
_userId: string,
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
): Promise<{ docIndex: DocIndex; docStore: DocStore; folderPaths: Map<string, string> }> {
|
||||
): Promise<{
|
||||
docIndex: DocIndex;
|
||||
docStore: DocStore;
|
||||
folderPaths: Map<string, string>;
|
||||
}> {
|
||||
const docIndex: DocIndex = {};
|
||||
const docStore: DocStore = new Map();
|
||||
|
||||
const [{ data: docs }, { data: folders }] = await Promise.all([
|
||||
db.from("documents")
|
||||
.select("id, filename, file_type, current_version_id, status, folder_id")
|
||||
db
|
||||
.from("documents")
|
||||
.select(
|
||||
"id, filename, file_type, current_version_id, status, folder_id",
|
||||
)
|
||||
.eq("project_id", projectId)
|
||||
.eq("status", "ready")
|
||||
.order("created_at", { ascending: true }),
|
||||
db.from("project_subfolders")
|
||||
db
|
||||
.from("project_subfolders")
|
||||
.select("id, name, parent_folder_id")
|
||||
.eq("project_id", projectId),
|
||||
]);
|
||||
|
|
@ -2739,8 +2918,15 @@ export async function buildProjectDocContext(
|
|||
await attachActiveVersionPaths(db, docList);
|
||||
|
||||
// Build folder id → full path map
|
||||
const folderMap = new Map<string, { name: string; parent_folder_id: string | null }>();
|
||||
for (const f of folders ?? []) folderMap.set(f.id, { name: f.name, parent_folder_id: f.parent_folder_id });
|
||||
const folderMap = new Map<
|
||||
string,
|
||||
{ name: string; parent_folder_id: string | null }
|
||||
>();
|
||||
for (const f of folders ?? [])
|
||||
folderMap.set(f.id, {
|
||||
name: f.name,
|
||||
parent_folder_id: f.parent_folder_id,
|
||||
});
|
||||
|
||||
function resolvePath(folderId: string | null): string {
|
||||
if (!folderId) return "";
|
||||
|
|
@ -2820,7 +3006,9 @@ export async function buildWorkflowStore(
|
|||
.from("workflow_shares")
|
||||
.select("workflow_id")
|
||||
.eq("shared_with_email", normalizedUserEmail);
|
||||
const sharedIds = [...new Set((shares ?? []).map((share) => share.workflow_id))];
|
||||
const sharedIds = [
|
||||
...new Set((shares ?? []).map((share) => share.workflow_id)),
|
||||
];
|
||||
if (sharedIds.length > 0) {
|
||||
const { data: sharedWorkflows } = await db
|
||||
.from("workflows")
|
||||
|
|
@ -2829,7 +3017,10 @@ export async function buildWorkflowStore(
|
|||
.eq("type", "assistant");
|
||||
for (const wf of sharedWorkflows ?? []) {
|
||||
if (wf.prompt_md) {
|
||||
store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
|
||||
store.set(wf.id, {
|
||||
title: wf.title,
|
||||
prompt_md: wf.prompt_md,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import type { Tool } from "@anthropic-ai/sdk/resources/messages/messages";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import type {
|
||||
StreamChatParams,
|
||||
StreamChatResult,
|
||||
|
|
@ -10,11 +8,6 @@ import type {
|
|||
} from "./types";
|
||||
import { toClaudeTools } from "./tools";
|
||||
|
||||
const RAW_STREAM_LOG_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
"claude-raw-stream.log",
|
||||
);
|
||||
|
||||
type ContentBlock =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: unknown }
|
||||
|
|
@ -80,12 +73,6 @@ export async function streamClaude(
|
|||
|
||||
let sawThinking = false;
|
||||
|
||||
stream.on("streamEvent", (event) => {
|
||||
const line = JSON.stringify(event);
|
||||
console.log("[claude raw stream]", line);
|
||||
fs.appendFile(RAW_STREAM_LOG_PATH, line + "\n", () => {});
|
||||
});
|
||||
|
||||
stream.on("text", (delta) => {
|
||||
callbacks.onContentDelta?.(delta);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,7 +77,6 @@ export async function streamGemini(
|
|||
let sawThinking = false;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
console.log("[gemini stream chunk]", JSON.stringify(chunk, null, 2));
|
||||
const parts =
|
||||
(chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] })
|
||||
.candidates?.[0]?.content?.parts ?? [];
|
||||
|
|
|
|||
|
|
@ -122,7 +122,9 @@ export function normalizeDownloadFilename(name: string): string {
|
|||
}
|
||||
|
||||
export function sanitizeDispositionFilename(name: string): string {
|
||||
return normalizeDownloadFilename(name).replace(/["\\]/g, "_");
|
||||
return normalizeDownloadFilename(name)
|
||||
.replace(/["\\]/g, "_")
|
||||
.replace(/[^\x20-\x7E]/g, "_");
|
||||
}
|
||||
|
||||
export function encodeRFC5987(str: string): string {
|
||||
|
|
|
|||
143
backend/src/lib/userApiKeys.ts
Normal file
143
backend/src/lib/userApiKeys.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import crypto from "crypto";
|
||||
import { createServerSupabase } from "./supabase";
|
||||
import type { UserApiKeys } from "./llm";
|
||||
|
||||
type Db = ReturnType<typeof createServerSupabase>;
|
||||
export type ApiKeyProvider = "claude" | "gemini";
|
||||
|
||||
type EncryptedKeyRow = {
|
||||
provider: ApiKeyProvider;
|
||||
encrypted_key: string;
|
||||
iv: string;
|
||||
auth_tag: string;
|
||||
};
|
||||
|
||||
const PROVIDERS: ApiKeyProvider[] = ["claude", "gemini"];
|
||||
|
||||
function encryptionKey(): Buffer {
|
||||
const secret =
|
||||
process.env.USER_API_KEYS_ENCRYPTION_SECRET ||
|
||||
process.env.API_KEYS_ENCRYPTION_SECRET ||
|
||||
process.env.SUPABASE_SECRET_KEY;
|
||||
if (!secret) {
|
||||
throw new Error("API key encryption secret is not configured");
|
||||
}
|
||||
return crypto.createHash("sha256").update(secret).digest();
|
||||
}
|
||||
|
||||
function encrypt(value: string): Omit<EncryptedKeyRow, "provider"> {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey(), iv);
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(value, "utf8"),
|
||||
cipher.final(),
|
||||
]);
|
||||
return {
|
||||
encrypted_key: encrypted.toString("base64"),
|
||||
iv: iv.toString("base64"),
|
||||
auth_tag: cipher.getAuthTag().toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function decrypt(row: EncryptedKeyRow): string | null {
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
encryptionKey(),
|
||||
Buffer.from(row.iv, "base64"),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(row.auth_tag, "base64"));
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(row.encrypted_key, "base64")),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString("utf8");
|
||||
} catch (err) {
|
||||
console.error("[user-api-keys] failed to decrypt stored key", {
|
||||
provider: row.provider,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isProvider(value: string): value is ApiKeyProvider {
|
||||
return (PROVIDERS as string[]).includes(value);
|
||||
}
|
||||
|
||||
export function normalizeApiKeyProvider(value: string): ApiKeyProvider | null {
|
||||
return isProvider(value) ? value : null;
|
||||
}
|
||||
|
||||
export async function getUserApiKeyStatus(
|
||||
userId: string,
|
||||
db: Db = createServerSupabase(),
|
||||
): Promise<Record<ApiKeyProvider, boolean>> {
|
||||
const status: Record<ApiKeyProvider, boolean> = {
|
||||
claude: false,
|
||||
gemini: false,
|
||||
};
|
||||
|
||||
const { data, error } = await db
|
||||
.from("user_api_keys")
|
||||
.select("provider")
|
||||
.eq("user_id", userId);
|
||||
if (error) throw error;
|
||||
|
||||
for (const row of data ?? []) {
|
||||
const provider = normalizeApiKeyProvider(String(row.provider));
|
||||
if (provider) status[provider] = true;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function getUserApiKeys(
|
||||
userId: string,
|
||||
db: Db = createServerSupabase(),
|
||||
): Promise<UserApiKeys> {
|
||||
const apiKeys: UserApiKeys = { claude: null, gemini: null };
|
||||
|
||||
const { data, error } = await db
|
||||
.from("user_api_keys")
|
||||
.select("provider, encrypted_key, iv, auth_tag")
|
||||
.eq("user_id", userId);
|
||||
if (error) throw error;
|
||||
|
||||
for (const row of (data ?? []) as EncryptedKeyRow[]) {
|
||||
const provider = normalizeApiKeyProvider(row.provider);
|
||||
if (!provider) continue;
|
||||
apiKeys[provider] = decrypt(row);
|
||||
}
|
||||
|
||||
return apiKeys;
|
||||
}
|
||||
|
||||
export async function saveUserApiKey(
|
||||
userId: string,
|
||||
provider: ApiKeyProvider,
|
||||
value: string | null,
|
||||
db: Db = createServerSupabase(),
|
||||
): Promise<void> {
|
||||
const normalized = value?.trim() || null;
|
||||
if (!normalized) {
|
||||
const { error } = await db
|
||||
.from("user_api_keys")
|
||||
.delete()
|
||||
.eq("user_id", userId)
|
||||
.eq("provider", provider);
|
||||
if (error) throw error;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await db.from("user_api_keys").upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
provider,
|
||||
...encrypt(normalized),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: "user_id,provider" },
|
||||
);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
DEFAULT_TABULAR_MODEL,
|
||||
type UserApiKeys,
|
||||
} from "./llm";
|
||||
import { getUserApiKeys as getStoredUserApiKeys } from "./userApiKeys";
|
||||
|
||||
export type UserModelSettings = {
|
||||
title_model: string;
|
||||
|
|
@ -29,14 +30,10 @@ export async function getUserModelSettings(
|
|||
const client = db ?? createServerSupabase();
|
||||
const { data } = await client
|
||||
.from("user_profiles")
|
||||
.select("tabular_model, claude_api_key, gemini_api_key")
|
||||
.select("tabular_model")
|
||||
.eq("user_id", userId)
|
||||
.single();
|
||||
|
||||
const api_keys: UserApiKeys = {
|
||||
claude: data?.claude_api_key ?? null,
|
||||
gemini: data?.gemini_api_key ?? null,
|
||||
};
|
||||
const api_keys = await getStoredUserApiKeys(userId, client);
|
||||
|
||||
return {
|
||||
title_model: resolveTitleModel(api_keys),
|
||||
|
|
@ -50,13 +47,5 @@ export async function getUserApiKeys(
|
|||
db?: ReturnType<typeof createServerSupabase>,
|
||||
): Promise<UserApiKeys> {
|
||||
const client = db ?? createServerSupabase();
|
||||
const { data } = await client
|
||||
.from("user_profiles")
|
||||
.select("claude_api_key, gemini_api_key")
|
||||
.eq("user_id", userId)
|
||||
.single();
|
||||
return {
|
||||
claude: data?.claude_api_key ?? null,
|
||||
gemini: data?.gemini_api_key ?? null,
|
||||
};
|
||||
return getStoredUserApiKeys(userId, client);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,118 @@ import { checkProjectAccess } from "../lib/access";
|
|||
|
||||
export const chatRouter = Router();
|
||||
|
||||
type Db = ReturnType<typeof createServerSupabase>;
|
||||
|
||||
type AccessibleChat = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
user_id: string;
|
||||
project_id: string | null;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
function parseOptionalProjectId(value: unknown):
|
||||
| { ok: true; provided: boolean; projectId: string | null }
|
||||
| { ok: false; detail: string } {
|
||||
if (value === undefined)
|
||||
return { ok: true, provided: false, projectId: null };
|
||||
if (value === null) return { ok: true, provided: true, projectId: null };
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
detail: "project_id must be a non-empty string or null",
|
||||
};
|
||||
}
|
||||
return { ok: true, provided: true, projectId: value.trim() };
|
||||
}
|
||||
|
||||
function parseOptionalChatId(value: unknown):
|
||||
| { ok: true; chatId: string | null }
|
||||
| { ok: false; detail: string } {
|
||||
if (value === undefined || value === null) return { ok: true, chatId: null };
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return { ok: false, detail: "chat_id must be a non-empty string" };
|
||||
}
|
||||
return { ok: true, chatId: value.trim() };
|
||||
}
|
||||
|
||||
function parseChatMessages(value: unknown):
|
||||
| { ok: true; messages: ChatMessage[] }
|
||||
| { ok: false; detail: string } {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return { ok: false, detail: "messages must be a non-empty array" };
|
||||
}
|
||||
|
||||
for (const message of value) {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return { ok: false, detail: "messages must contain objects" };
|
||||
}
|
||||
const row = message as Record<string, unknown>;
|
||||
if (typeof row.role !== "string") {
|
||||
return { ok: false, detail: "message.role must be a string" };
|
||||
}
|
||||
if (row.content !== null && typeof row.content !== "string") {
|
||||
return {
|
||||
ok: false,
|
||||
detail: "message.content must be a string or null",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, messages: value as ChatMessage[] };
|
||||
}
|
||||
|
||||
function parseOptionalModel(value: unknown):
|
||||
| { ok: true; model: string | undefined }
|
||||
| { ok: false; detail: string } {
|
||||
if (value === undefined) return { ok: true, model: undefined };
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return { ok: false, detail: "model must be a non-empty string" };
|
||||
}
|
||||
return { ok: true, model: value.trim() };
|
||||
}
|
||||
|
||||
async function validateAccessibleProjectId(
|
||||
projectId: string | null,
|
||||
userId: string,
|
||||
userEmail: string | null | undefined,
|
||||
db: Db,
|
||||
): Promise<{ ok: true } | { ok: false; status: number; detail: string }> {
|
||||
if (!projectId) return { ok: true };
|
||||
const access = await checkProjectAccess(projectId, userId, userEmail, db);
|
||||
if (!access.ok)
|
||||
return { ok: false, status: 404, detail: "Project not found" };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function getAccessibleChat(
|
||||
chatId: string,
|
||||
userId: string,
|
||||
userEmail: string | null | undefined,
|
||||
db: Db,
|
||||
): Promise<AccessibleChat | null> {
|
||||
const { data: chat, error } = await db
|
||||
.from("chats")
|
||||
.select("*")
|
||||
.eq("id", chatId)
|
||||
.maybeSingle();
|
||||
if (error || !chat) return null;
|
||||
|
||||
const row = chat as AccessibleChat;
|
||||
if (row.user_id === userId) return row;
|
||||
|
||||
if (row.project_id) {
|
||||
const access = await checkProjectAccess(
|
||||
row.project_id,
|
||||
userId,
|
||||
userEmail,
|
||||
db,
|
||||
);
|
||||
if (access.ok) return row;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// GET /chat
|
||||
// Visible chats = the user's own chats + every chat under a project the
|
||||
// user owns (so a project owner sees all collaborator chats in their
|
||||
|
|
@ -52,11 +164,27 @@ chatRouter.get("/", requireAuth, async (req, res) => {
|
|||
// POST /chat/create
|
||||
chatRouter.post("/create", requireAuth, async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const projectId: string | null = req.body.project_id ?? null;
|
||||
const userEmail = res.locals.userEmail as string | undefined;
|
||||
const parsedProjectId = parseOptionalProjectId(req.body?.project_id);
|
||||
if (!parsedProjectId.ok) {
|
||||
return void res.status(400).json({ detail: parsedProjectId.detail });
|
||||
}
|
||||
const projectId = parsedProjectId.projectId;
|
||||
const db = createServerSupabase();
|
||||
const projectAccess = await validateAccessibleProjectId(
|
||||
projectId,
|
||||
userId,
|
||||
userEmail,
|
||||
db,
|
||||
);
|
||||
if (!projectAccess.ok)
|
||||
return void res
|
||||
.status(projectAccess.status)
|
||||
.json({ detail: projectAccess.detail });
|
||||
|
||||
const { data, error } = await db
|
||||
.from("chats")
|
||||
.insert({ user_id: userId, project_id: projectId ?? undefined })
|
||||
.insert({ user_id: userId, project_id: projectId ?? null })
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
|
|
@ -71,25 +199,8 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => {
|
|||
const { chatId } = req.params;
|
||||
const db = createServerSupabase();
|
||||
|
||||
const { data: chat, error } = await db
|
||||
.from("chats")
|
||||
.select("*")
|
||||
.eq("id", chatId)
|
||||
.single();
|
||||
if (error || !chat)
|
||||
return void res.status(404).json({ detail: "Chat not found" });
|
||||
// Owner of the chat OR a member of the chat's project can view it.
|
||||
let canView = chat.user_id === userId;
|
||||
if (!canView && chat.project_id) {
|
||||
const access = await checkProjectAccess(
|
||||
chat.project_id,
|
||||
userId,
|
||||
userEmail,
|
||||
db,
|
||||
);
|
||||
canView = access.ok;
|
||||
}
|
||||
if (!canView)
|
||||
const chat = await getAccessibleChat(chatId, userId, userEmail, db);
|
||||
if (!chat)
|
||||
return void res.status(404).json({ detail: "Chat not found" });
|
||||
|
||||
const { data: messages } = await db
|
||||
|
|
@ -261,30 +372,14 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => {
|
|||
const userId = res.locals.userId as string;
|
||||
const userEmail = res.locals.userEmail as string | undefined;
|
||||
const { chatId } = req.params;
|
||||
const message: string = (req.body.message ?? "").trim();
|
||||
const message =
|
||||
typeof req.body?.message === "string" ? req.body.message.trim() : "";
|
||||
if (!message)
|
||||
return void res.status(400).json({ detail: "message is required" });
|
||||
|
||||
const db = createServerSupabase();
|
||||
const { data: chat, error } = await db
|
||||
.from("chats")
|
||||
.select("id, user_id, project_id")
|
||||
.eq("id", chatId)
|
||||
.single();
|
||||
|
||||
if (error || !chat)
|
||||
return void res.status(404).json({ detail: "Chat not found" });
|
||||
let canTitle = chat.user_id === userId;
|
||||
if (!canTitle && chat.project_id) {
|
||||
const access = await checkProjectAccess(
|
||||
chat.project_id,
|
||||
userId,
|
||||
userEmail,
|
||||
db,
|
||||
);
|
||||
canTitle = access.ok;
|
||||
}
|
||||
if (!canTitle)
|
||||
const chat = await getAccessibleChat(chatId, userId, userEmail, db);
|
||||
if (!chat)
|
||||
return void res.status(404).json({ detail: "Chat not found" });
|
||||
|
||||
try {
|
||||
|
|
@ -303,8 +398,7 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => {
|
|||
await db
|
||||
.from("chats")
|
||||
.update({ title })
|
||||
.eq("id", chatId)
|
||||
.eq("user_id", userId);
|
||||
.eq("id", chatId);
|
||||
|
||||
res.json({ title });
|
||||
} catch (err) {
|
||||
|
|
@ -316,12 +410,31 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => {
|
|||
// POST /chat — streaming
|
||||
chatRouter.post("/", requireAuth, async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const { messages, chat_id, project_id, model } = req.body as {
|
||||
messages: ChatMessage[];
|
||||
chat_id?: string;
|
||||
project_id?: string;
|
||||
model?: string;
|
||||
};
|
||||
const body =
|
||||
req.body && typeof req.body === "object" && !Array.isArray(req.body)
|
||||
? (req.body as Record<string, unknown>)
|
||||
: {};
|
||||
const parsedMessages = parseChatMessages(body.messages);
|
||||
if (!parsedMessages.ok) {
|
||||
return void res.status(400).json({ detail: parsedMessages.detail });
|
||||
}
|
||||
const parsedChatId = parseOptionalChatId(body.chat_id);
|
||||
if (!parsedChatId.ok) {
|
||||
return void res.status(400).json({ detail: parsedChatId.detail });
|
||||
}
|
||||
const parsedProjectId = parseOptionalProjectId(body.project_id);
|
||||
if (!parsedProjectId.ok) {
|
||||
return void res.status(400).json({ detail: parsedProjectId.detail });
|
||||
}
|
||||
const parsedModel = parseOptionalModel(body.model);
|
||||
if (!parsedModel.ok) {
|
||||
return void res.status(400).json({ detail: parsedModel.detail });
|
||||
}
|
||||
|
||||
const messages = parsedMessages.messages;
|
||||
const chat_id = parsedChatId.chatId;
|
||||
const project_id = parsedProjectId.projectId;
|
||||
const model = parsedModel.model;
|
||||
|
||||
console.log("[chat/stream] incoming request", {
|
||||
userId,
|
||||
|
|
@ -335,46 +448,43 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
const db = createServerSupabase();
|
||||
let chatId = chat_id ?? null;
|
||||
let chatTitle: string | null = null;
|
||||
let resolvedProjectId: string | null = parsedProjectId.projectId;
|
||||
|
||||
if (chatId) {
|
||||
// Either chat owner OR a member of the chat's project can post.
|
||||
const { data: existing } = await db
|
||||
.from("chats")
|
||||
.select("id, title, user_id, project_id")
|
||||
.eq("id", chatId)
|
||||
.single();
|
||||
let canUse = !!existing && existing.user_id === userId;
|
||||
if (!canUse && existing?.project_id) {
|
||||
const access = await checkProjectAccess(
|
||||
existing.project_id,
|
||||
userId,
|
||||
userEmail,
|
||||
db,
|
||||
);
|
||||
canUse = access.ok;
|
||||
const existing = await getAccessibleChat(chatId, userId, userEmail, db);
|
||||
if (!existing)
|
||||
return void res.status(404).json({ detail: "Chat not found" });
|
||||
|
||||
const existingProjectId = existing.project_id ?? null;
|
||||
if (
|
||||
parsedProjectId.provided &&
|
||||
parsedProjectId.projectId !== existingProjectId
|
||||
) {
|
||||
return void res
|
||||
.status(400)
|
||||
.json({ detail: "project_id does not match chat" });
|
||||
}
|
||||
if (!canUse || !existing) chatId = null;
|
||||
else chatTitle = existing.title;
|
||||
resolvedProjectId = existingProjectId;
|
||||
chatTitle = existing.title;
|
||||
}
|
||||
|
||||
if (!chatId) {
|
||||
// If creating a chat tied to a project, the user must have access
|
||||
// to the project (own or shared).
|
||||
if (project_id) {
|
||||
const access = await checkProjectAccess(
|
||||
project_id,
|
||||
userId,
|
||||
userEmail,
|
||||
db,
|
||||
);
|
||||
if (!access.ok)
|
||||
return void res
|
||||
.status(404)
|
||||
.json({ detail: "Project not found" });
|
||||
}
|
||||
const projectAccess = await validateAccessibleProjectId(
|
||||
resolvedProjectId,
|
||||
userId,
|
||||
userEmail,
|
||||
db,
|
||||
);
|
||||
if (!projectAccess.ok)
|
||||
return void res
|
||||
.status(projectAccess.status)
|
||||
.json({ detail: projectAccess.detail });
|
||||
|
||||
const { data: newChat, error } = await db
|
||||
.from("chats")
|
||||
.insert({ user_id: userId, project_id: project_id ?? null })
|
||||
.insert({ user_id: userId, project_id: resolvedProjectId })
|
||||
.select("id, title")
|
||||
.single();
|
||||
if (error || !newChat) {
|
||||
|
|
@ -449,7 +559,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
|||
workflowStore,
|
||||
model,
|
||||
apiKeys,
|
||||
projectId: project_id ?? null,
|
||||
projectId: resolvedProjectId,
|
||||
});
|
||||
|
||||
console.log("[chat/stream] LLM stream finished", {
|
||||
|
|
|
|||
|
|
@ -526,11 +526,14 @@ projectsRouter.patch("/:projectId/folders/:folderId", requireAuth, async (req, r
|
|||
if ("parent_folder_id" in body) {
|
||||
// Cycle check: walk up the tree from the proposed parent to ensure folderId is not an ancestor
|
||||
if (body.parent_folder_id) {
|
||||
const parent = await loadProjectFolder(db, projectId, body.parent_folder_id);
|
||||
if (!parent) return void res.status(404).json({ detail: "Parent folder not found" });
|
||||
|
||||
let cur: string | null = body.parent_folder_id;
|
||||
while (cur) {
|
||||
if (cur === folderId) return void res.status(400).json({ detail: "Cannot move a folder into itself or a descendant" });
|
||||
const { data: p }: { data: { parent_folder_id: string | null } | null } =
|
||||
await db.from("project_subfolders").select("parent_folder_id").eq("id", cur).single();
|
||||
const p = await loadProjectFolder(db, projectId, cur);
|
||||
if (!p) return void res.status(404).json({ detail: "Parent folder not found" });
|
||||
cur = p?.parent_folder_id ?? null;
|
||||
}
|
||||
}
|
||||
|
|
@ -555,8 +558,11 @@ projectsRouter.delete("/:projectId/folders/:folderId", requireAuth, async (req,
|
|||
const access = await checkProjectAccess(projectId, userId, userEmail, db);
|
||||
if (!access.ok) return void res.status(404).json({ detail: "Project not found" });
|
||||
|
||||
const folder = await loadProjectFolder(db, projectId, folderId);
|
||||
if (!folder) return void res.status(404).json({ detail: "Folder not found" });
|
||||
|
||||
// Move direct documents to root before cascade-deleting subfolders
|
||||
await db.from("documents").update({ folder_id: null }).eq("folder_id", folderId);
|
||||
await db.from("documents").update({ folder_id: null }).eq("folder_id", folderId).eq("project_id", projectId);
|
||||
|
||||
const { error } = await db.from("project_subfolders")
|
||||
.delete().eq("id", folderId).eq("project_id", projectId);
|
||||
|
|
@ -575,6 +581,11 @@ projectsRouter.patch("/:projectId/documents/:documentId/folder", requireAuth, as
|
|||
const access = await checkProjectAccess(projectId, userId, userEmail, db);
|
||||
if (!access.ok) return void res.status(404).json({ detail: "Project not found" });
|
||||
|
||||
if (folder_id) {
|
||||
const folder = await loadProjectFolder(db, projectId, folder_id);
|
||||
if (!folder) return void res.status(404).json({ detail: "Folder not found" });
|
||||
}
|
||||
|
||||
const { data, error } = await db.from("documents")
|
||||
.update({ folder_id: folder_id ?? null, updated_at: new Date().toISOString() })
|
||||
.eq("id", documentId).eq("project_id", projectId)
|
||||
|
|
@ -583,6 +594,20 @@ projectsRouter.patch("/:projectId/documents/:documentId/folder", requireAuth, as
|
|||
res.json(data);
|
||||
});
|
||||
|
||||
async function loadProjectFolder(
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
projectId: string,
|
||||
folderId: string,
|
||||
): Promise<{ id: string; parent_folder_id: string | null } | null> {
|
||||
const { data } = await db
|
||||
.from("project_subfolders")
|
||||
.select("id, parent_folder_id")
|
||||
.eq("id", folderId)
|
||||
.eq("project_id", projectId)
|
||||
.maybeSingle();
|
||||
return (data as { id: string; parent_folder_id: string | null } | null) ?? null;
|
||||
}
|
||||
|
||||
export async function handleDocumentUpload(
|
||||
req: import("express").Request,
|
||||
res: import("express").Response,
|
||||
|
|
|
|||
|
|
@ -1,23 +1,250 @@
|
|||
import { Router } from "express";
|
||||
import { requireAuth } from "../middleware/auth";
|
||||
import { createServerSupabase } from "../lib/supabase";
|
||||
import { DEFAULT_TABULAR_MODEL, resolveModel } from "../lib/llm";
|
||||
import {
|
||||
getUserApiKeyStatus,
|
||||
normalizeApiKeyProvider,
|
||||
saveUserApiKey,
|
||||
} from "../lib/userApiKeys";
|
||||
|
||||
export const userRouter = Router();
|
||||
|
||||
// POST /user/profile
|
||||
userRouter.post("/profile", requireAuth, async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const db = createServerSupabase();
|
||||
const MONTHLY_CREDIT_LIMIT = 999999;
|
||||
|
||||
type UserProfileRow = {
|
||||
display_name: string | null;
|
||||
organisation: string | null;
|
||||
message_credits_used: number;
|
||||
credits_reset_date: string;
|
||||
tier: string;
|
||||
tabular_model: string;
|
||||
};
|
||||
|
||||
function serializeProfile(
|
||||
row: UserProfileRow,
|
||||
apiKeyStatus?: { claude: boolean; gemini: boolean },
|
||||
) {
|
||||
const creditsUsed = row.message_credits_used ?? 0;
|
||||
return {
|
||||
displayName: row.display_name,
|
||||
organisation: row.organisation,
|
||||
messageCreditsUsed: creditsUsed,
|
||||
creditsResetDate: row.credits_reset_date,
|
||||
creditsRemaining: Math.max(MONTHLY_CREDIT_LIMIT - creditsUsed, 0),
|
||||
tier: row.tier || "Free",
|
||||
tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL),
|
||||
...(apiKeyStatus ? { apiKeyStatus } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateProfilePayload(body: unknown):
|
||||
| {
|
||||
ok: true;
|
||||
update: {
|
||||
display_name?: string | null;
|
||||
organisation?: string | null;
|
||||
tabular_model?: string;
|
||||
updated_at: string;
|
||||
};
|
||||
}
|
||||
| { ok: false; detail: string } {
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
||||
return { ok: false, detail: "Expected a JSON object" };
|
||||
}
|
||||
|
||||
const raw = body as Record<string, unknown>;
|
||||
const allowedFields = new Set([
|
||||
"displayName",
|
||||
"organisation",
|
||||
"tabularModel",
|
||||
]);
|
||||
const invalidField = Object.keys(raw).find((key) => !allowedFields.has(key));
|
||||
if (invalidField) {
|
||||
return { ok: false, detail: `Unsupported profile field: ${invalidField}` };
|
||||
}
|
||||
|
||||
const update: {
|
||||
display_name?: string | null;
|
||||
organisation?: string | null;
|
||||
tabular_model?: string;
|
||||
updated_at: string;
|
||||
} = { updated_at: new Date().toISOString() };
|
||||
|
||||
if ("displayName" in raw) {
|
||||
if (raw.displayName !== null && typeof raw.displayName !== "string") {
|
||||
return { ok: false, detail: "displayName must be a string or null" };
|
||||
}
|
||||
update.display_name = raw.displayName?.trim() || null;
|
||||
}
|
||||
|
||||
if ("organisation" in raw) {
|
||||
if (raw.organisation !== null && typeof raw.organisation !== "string") {
|
||||
return { ok: false, detail: "organisation must be a string or null" };
|
||||
}
|
||||
update.organisation = raw.organisation?.trim() || null;
|
||||
}
|
||||
|
||||
if ("tabularModel" in raw) {
|
||||
if (typeof raw.tabularModel !== "string") {
|
||||
return { ok: false, detail: "tabularModel must be a string" };
|
||||
}
|
||||
const resolved = resolveModel(raw.tabularModel, "");
|
||||
if (!resolved) {
|
||||
return { ok: false, detail: "Unsupported tabularModel" };
|
||||
}
|
||||
update.tabular_model = resolved;
|
||||
}
|
||||
|
||||
return { ok: true, update };
|
||||
}
|
||||
|
||||
async function ensureProfileRow(
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
userId: string,
|
||||
) {
|
||||
const { error } = await db
|
||||
.from("user_profiles")
|
||||
.upsert(
|
||||
{ user_id: userId },
|
||||
{ onConflict: "user_id", ignoreDuplicates: true },
|
||||
);
|
||||
return error;
|
||||
}
|
||||
|
||||
async function loadProfile(
|
||||
db: ReturnType<typeof createServerSupabase>,
|
||||
userId: string,
|
||||
options: { repairMissing?: boolean } = {},
|
||||
) {
|
||||
let { data, error } = await db
|
||||
.from("user_profiles")
|
||||
.select(
|
||||
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model",
|
||||
)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) return { data: null, error };
|
||||
if (!data) {
|
||||
if (!options.repairMissing) {
|
||||
return { data: null, error: new Error("Profile not found") };
|
||||
}
|
||||
|
||||
const ensureError = await ensureProfileRow(db, userId);
|
||||
if (ensureError) return { data: null, error: ensureError };
|
||||
|
||||
const created = await db
|
||||
.from("user_profiles")
|
||||
.select(
|
||||
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model",
|
||||
)
|
||||
.eq("user_id", userId)
|
||||
.single();
|
||||
if (created.error) return { data: null, error: created.error };
|
||||
data = created.data;
|
||||
}
|
||||
|
||||
let row = data as UserProfileRow;
|
||||
if (row.credits_reset_date && new Date() > new Date(row.credits_reset_date)) {
|
||||
const creditsResetDate = new Date();
|
||||
creditsResetDate.setDate(creditsResetDate.getDate() + 30);
|
||||
const { data: resetData, error: resetError } = await db
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
message_credits_used: 0,
|
||||
credits_reset_date: creditsResetDate.toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", userId)
|
||||
.select(
|
||||
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model",
|
||||
)
|
||||
.single();
|
||||
|
||||
if (resetError) return { data: null, error: resetError };
|
||||
row = resetData as UserProfileRow;
|
||||
}
|
||||
|
||||
return { data: serializeProfile(row), error: null };
|
||||
}
|
||||
|
||||
// POST /user/profile
|
||||
userRouter.post("/profile", requireAuth, async (_req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const db = createServerSupabase();
|
||||
const error = await ensureProfileRow(db, userId);
|
||||
if (error) return void res.status(500).json({ detail: error.message });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /user/profile
|
||||
userRouter.get("/profile", requireAuth, async (_req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const db = createServerSupabase();
|
||||
const { data, error } = await loadProfile(db, userId, {
|
||||
repairMissing: true,
|
||||
});
|
||||
if (error) return void res.status(500).json({ detail: error.message });
|
||||
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
|
||||
res.json({ ...data, apiKeyStatus });
|
||||
});
|
||||
|
||||
// PATCH /user/profile
|
||||
userRouter.patch("/profile", requireAuth, async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const parsed = validateProfilePayload(req.body);
|
||||
if (!parsed.ok) return void res.status(400).json({ detail: parsed.detail });
|
||||
|
||||
const db = createServerSupabase();
|
||||
const ensureError = await ensureProfileRow(db, userId);
|
||||
if (ensureError)
|
||||
return void res.status(500).json({ detail: ensureError.message });
|
||||
|
||||
const { error: updateError } = await db
|
||||
.from("user_profiles")
|
||||
.update(parsed.update)
|
||||
.eq("user_id", userId);
|
||||
if (updateError)
|
||||
return void res.status(500).json({ detail: updateError.message });
|
||||
|
||||
const { data, error } = await loadProfile(db, userId);
|
||||
if (error) return void res.status(500).json({ detail: error.message });
|
||||
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
|
||||
res.json({ ...data, apiKeyStatus });
|
||||
});
|
||||
|
||||
// GET /user/api-keys
|
||||
userRouter.get("/api-keys", requireAuth, async (_req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const db = createServerSupabase();
|
||||
const status = await getUserApiKeyStatus(userId, db);
|
||||
res.json(status);
|
||||
});
|
||||
|
||||
// PUT /user/api-keys/:provider
|
||||
userRouter.put("/api-keys/:provider", requireAuth, async (req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
const provider = normalizeApiKeyProvider(req.params.provider);
|
||||
if (!provider)
|
||||
return void res.status(400).json({ detail: "Unsupported provider" });
|
||||
|
||||
const apiKey =
|
||||
typeof req.body?.api_key === "string" ? req.body.api_key : null;
|
||||
const db = createServerSupabase();
|
||||
try {
|
||||
await saveUserApiKey(userId, provider, apiKey, db);
|
||||
const status = await getUserApiKeyStatus(userId, db);
|
||||
res.json(status);
|
||||
} catch (err) {
|
||||
console.error("[user/api-keys] save failed", {
|
||||
provider,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
res.status(500).json({ detail: "Failed to save API key" });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /user/account
|
||||
userRouter.delete("/account", requireAuth, async (_req, res) => {
|
||||
const userId = res.locals.userId as string;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node", "express", "cors", "multer"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "frontend-app",
|
||||
"name": "mike",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1025.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1025.0",
|
||||
|
|
|
|||
1
frontend/package-lock.json
generated
1
frontend/package-lock.json
generated
|
|
@ -7,7 +7,6 @@
|
|||
"": {
|
||||
"name": "mike",
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1025.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1025.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
{
|
||||
"name": "mike",
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
|
@ -69,5 +68,6 @@
|
|||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"wrangler": "^4.51.0"
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
|
|
|
|||
BIN
frontend/public/link-image.jpg
Normal file
BIN
frontend/public/link-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
|
|
@ -74,18 +74,20 @@ export default function ModelsAndApiKeysPage() {
|
|||
<ApiKeyField
|
||||
label="Anthropic (Claude) API Key"
|
||||
placeholder="sk-ant-…"
|
||||
initialValue={profile?.claudeApiKey ?? ""}
|
||||
hasSavedKey={!!profile?.claudeApiKey}
|
||||
onSave={(value) =>
|
||||
updateApiKey("claude", value.trim() || null)
|
||||
}
|
||||
onRemove={() => updateApiKey("claude", null)}
|
||||
/>
|
||||
<ApiKeyField
|
||||
label="Google (Gemini) API Key"
|
||||
placeholder="AI…"
|
||||
initialValue={profile?.geminiApiKey ?? ""}
|
||||
hasSavedKey={!!profile?.geminiApiKey}
|
||||
onSave={(value) =>
|
||||
updateApiKey("gemini", value.trim() || null)
|
||||
}
|
||||
onRemove={() => updateApiKey("gemini", null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -183,30 +185,33 @@ function TabularModelDropdown({
|
|||
function ApiKeyField({
|
||||
label,
|
||||
placeholder,
|
||||
initialValue,
|
||||
hasSavedKey,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
initialValue: string;
|
||||
hasSavedKey: boolean;
|
||||
onSave: (value: string) => Promise<boolean>;
|
||||
onRemove: () => Promise<boolean>;
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [value, setValue] = useState("");
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
setValue("");
|
||||
}, [hasSavedKey]);
|
||||
|
||||
const dirty = value !== initialValue;
|
||||
const dirty = value.trim().length > 0;
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
const ok = await onSave(value);
|
||||
setIsSaving(false);
|
||||
if (ok) {
|
||||
setValue("");
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} else {
|
||||
|
|
@ -214,16 +219,28 @@ function ApiKeyField({
|
|||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsSaving(true);
|
||||
const ok = await onRemove();
|
||||
setIsSaving(false);
|
||||
if (!ok) alert(`Failed to remove ${label}.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="text-sm text-gray-600 block mb-2">{label}</label>
|
||||
{hasSavedKey && (
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
A key is saved. Paste a new key to replace it.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={reveal ? "text" : "password"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
placeholder={hasSavedKey ? "Saved key hidden" : placeholder}
|
||||
className="pr-10"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
|
|
@ -257,6 +274,16 @@ function ApiKeyField({
|
|||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
{hasSavedKey && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
13
frontend/src/app/(pages)/projects/[id]/assistant/page.tsx
Normal file
13
frontend/src/app/(pages)/projects/[id]/assistant/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { ProjectPage } from "@/app/components/projects/ProjectPage";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function ProjectAssistantPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
return <ProjectPage projectId={id} initialTab="assistant" />;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { ProjectPage } from "@/app/components/projects/ProjectPage";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function ProjectTabularReviewsPage({ params }: Props) {
|
||||
const { id } = use(params);
|
||||
return <ProjectPage projectId={id} initialTab="reviews" />;
|
||||
}
|
||||
|
|
@ -67,10 +67,12 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
} | null>(null);
|
||||
const [model, setModel] = useSelectedModel();
|
||||
const { profile } = useUserProfile();
|
||||
const apiKeys = {
|
||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
||||
};
|
||||
const apiKeys = profile
|
||||
? {
|
||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
||||
}
|
||||
: undefined;
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [docSelectorOpen, setDocSelectorOpen] = useState(false);
|
||||
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
|
||||
|
|
@ -116,7 +118,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
|||
const handleSubmit = () => {
|
||||
const query = value.trim();
|
||||
if (!query || isLoading) return;
|
||||
if (!isModelAvailable(model, apiKeys)) {
|
||||
if (apiKeys && !isModelAvailable(model, apiKeys)) {
|
||||
setApiKeyModalProvider(getModelProvider(model));
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
|||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
initialTab?: Tab;
|
||||
}
|
||||
|
||||
type Tab = "documents" | "assistant" | "reviews";
|
||||
|
|
@ -271,7 +272,7 @@ function DocVersionHistory({
|
|||
);
|
||||
}
|
||||
|
||||
export function ProjectPage({ projectId }: Props) {
|
||||
export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||
const [project, setProject] = useState<MikeProject | null>(null);
|
||||
const [folders, setFolders] = useState<MikeFolder[]>([]);
|
||||
const [chats, setChats] = useState<MikeChat[]>([]);
|
||||
|
|
@ -282,7 +283,7 @@ export function ProjectPage({ projectId }: Props) {
|
|||
const tab: Tab =
|
||||
tabParam === "assistant" || tabParam === "reviews"
|
||||
? tabParam
|
||||
: "documents";
|
||||
: initialTab;
|
||||
const [addDocsOpen, setAddDocsOpen] = useState(false);
|
||||
const [peopleModalOpen, setPeopleModalOpen] = useState(false);
|
||||
const [ownerOnlyAction, setOwnerOnlyAction] = useState<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -447,6 +447,7 @@ function TRChatInput({
|
|||
model,
|
||||
onModelChange,
|
||||
apiKeys,
|
||||
onHeightChange,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
onSubmit: (value: string) => void;
|
||||
|
|
@ -454,10 +455,42 @@ function TRChatInput({
|
|||
model: string;
|
||||
onModelChange: (id: string) => void;
|
||||
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null };
|
||||
onHeightChange: (height: number) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const notify = () => {
|
||||
onHeightChange(root.getBoundingClientRect().height);
|
||||
};
|
||||
notify();
|
||||
|
||||
const observer = new ResizeObserver(notify);
|
||||
observer.observe(root);
|
||||
window.addEventListener("resize", notify);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", notify);
|
||||
};
|
||||
}, [onHeightChange]);
|
||||
|
||||
function resizeTextarea(el: HTMLTextAreaElement) {
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 192)}px`;
|
||||
el.style.overflowY = el.scrollHeight > 192 ? "auto" : "hidden";
|
||||
}
|
||||
|
||||
function resetTextarea() {
|
||||
if (!textareaRef.current) return;
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.overflowY = "hidden";
|
||||
}
|
||||
|
||||
function handleAction() {
|
||||
if (isLoading) {
|
||||
onCancel();
|
||||
|
|
@ -466,13 +499,16 @@ function TRChatInput({
|
|||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
setValue("");
|
||||
if (textareaRef.current) textareaRef.current.style.height = "auto";
|
||||
resetTextarea();
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 mx-4 pb-4 bg-white">
|
||||
<div className="border border-gray-300 rounded-xl bg-white pt-1.5 pb-1.5 flex flex-col gap-1">
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="absolute bottom-0 left-0 right-0 px-4 pb-4 bg-white"
|
||||
>
|
||||
<div className="border border-gray-300 rounded-xl bg-white pt-2 pb-1.5 flex flex-col gap-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
|
|
@ -480,8 +516,7 @@ function TRChatInput({
|
|||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
resizeTextarea(e.target);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
|
|
@ -489,7 +524,7 @@ function TRChatInput({
|
|||
handleAction();
|
||||
}
|
||||
}}
|
||||
className="flex-1 resize-none text-sm bg-transparent outline-none placeholder:text-gray-400 leading-6 max-h-48 overflow-y-auto border-0 p-0 pl-3 pr-2 pt-1"
|
||||
className="w-full resize-none text-sm bg-transparent outline-none placeholder:text-gray-400 leading-6 max-h-48 overflow-hidden border-0 p-0 pl-3 pr-2 pt-0.5"
|
||||
/>
|
||||
<div className="flex items-center justify-between pl-1 pr-2">
|
||||
<ModelToggle
|
||||
|
|
@ -629,6 +664,7 @@ export function TRChatPanel({
|
|||
const [messagesVisible, setMessagesVisible] = useState(false);
|
||||
const [panelWidth, setPanelWidth] = useState(380);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [inputHeight, setInputHeight] = useState(96);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
|
|
@ -1392,7 +1428,8 @@ export function TRChatPanel({
|
|||
{/* Messages */}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 overflow-y-auto px-4 pt-4 pb-[96px] flex flex-col"
|
||||
className="flex-1 overflow-y-auto px-4 pt-4 flex flex-col"
|
||||
style={{ paddingBottom: Math.ceil(inputHeight + 16) }}
|
||||
>
|
||||
{messages.length === 0 && !isLoadingMessages && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2">
|
||||
|
|
@ -1458,6 +1495,7 @@ export function TRChatPanel({
|
|||
updateModelPreference("tabularModel", id)
|
||||
}
|
||||
apiKeys={apiKeys}
|
||||
onHeightChange={setInputHeight}
|
||||
/>
|
||||
|
||||
<ApiKeyMissingModal
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ export default function GlobalError({
|
|||
<title>Something went wrong – Mike</title>
|
||||
<style>{`
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=EB+Garamond:wght@400;500&display=swap');
|
||||
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background-color: #ffffff;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { streamChat, streamProjectChat } from "@/app/lib/mikeApi";
|
||||
import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext";
|
||||
|
|
@ -70,6 +70,14 @@ export function useAssistantChat({
|
|||
const events = last.events ?? [];
|
||||
const idx = findLastContentIndex(events);
|
||||
if (idx < 0) return prev;
|
||||
const current = events[idx];
|
||||
if (
|
||||
current.type === "content" &&
|
||||
current.text === text &&
|
||||
!!current.isStreaming === !!isStreaming
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const newEvents = [...events];
|
||||
newEvents[idx] = isStreaming
|
||||
? { type: "content", text, isStreaming: true }
|
||||
|
|
@ -149,7 +157,10 @@ export function useAssistantChat({
|
|||
dripIntervalRef.current = setInterval(() => {
|
||||
const target = dripTargetRef.current;
|
||||
const displayLen = dripDisplayLenRef.current;
|
||||
if (displayLen >= target.length) return;
|
||||
if (displayLen >= target.length) {
|
||||
stopDrip();
|
||||
return;
|
||||
}
|
||||
|
||||
const newLen = Math.min(
|
||||
displayLen + DRIP_CHARS_PER_TICK,
|
||||
|
|
@ -173,9 +184,17 @@ export function useAssistantChat({
|
|||
setMessages((prev) =>
|
||||
updateLastContentEvent(prev, visibleText, true),
|
||||
);
|
||||
|
||||
if (newLen >= target.length) {
|
||||
stopDrip();
|
||||
}
|
||||
}, 16);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => stopDrip();
|
||||
}, []);
|
||||
|
||||
const cancel = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
|
|
@ -822,8 +841,8 @@ export function useAssistantChat({
|
|||
}
|
||||
|
||||
return streamedChatId || null;
|
||||
} catch (error: any) {
|
||||
if (error.name === "AbortError") {
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
flushDrip();
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
|
|
@ -873,7 +892,7 @@ export function useAssistantChat({
|
|||
} else {
|
||||
stopDrip();
|
||||
const errorMessage =
|
||||
typeof error?.message === "string" && error.message
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: "Sorry, something went wrong.";
|
||||
setMessages((prev) => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const ebGaramond = EB_Garamond({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://app.mikeoss.com"),
|
||||
title: "Mike - AI Legal Platform",
|
||||
description:
|
||||
"AI-powered legal document analysis and contract review platform.",
|
||||
|
|
@ -25,6 +26,29 @@ export const metadata: Metadata = {
|
|||
],
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
url: "https://app.mikeoss.com",
|
||||
siteName: "Mike",
|
||||
title: "Mike - AI Legal Platform",
|
||||
description:
|
||||
"AI-powered legal document analysis and contract review platform.",
|
||||
images: [
|
||||
{
|
||||
url: "/link-image.jpg",
|
||||
width: 1200,
|
||||
height: 651,
|
||||
alt: "Mike",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Mike - AI Legal Platform",
|
||||
description:
|
||||
"AI-powered legal document analysis and contract review platform.",
|
||||
images: ["/link-image.jpg"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
|
|||
|
|
@ -97,6 +97,53 @@ export async function deleteAccount(): Promise<void> {
|
|||
return apiRequest<void>("/user/account", { method: "DELETE" });
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
displayName: string | null;
|
||||
organisation: string | null;
|
||||
messageCreditsUsed: number;
|
||||
creditsResetDate: string;
|
||||
creditsRemaining: number;
|
||||
tier: string;
|
||||
tabularModel: string;
|
||||
apiKeyStatus: ApiKeyStatus;
|
||||
}
|
||||
|
||||
export async function getUserProfile(): Promise<UserProfile> {
|
||||
return apiRequest<UserProfile>("/user/profile");
|
||||
}
|
||||
|
||||
export async function updateUserProfile(payload: {
|
||||
displayName?: string | null;
|
||||
organisation?: string | null;
|
||||
tabularModel?: string;
|
||||
}): Promise<UserProfile> {
|
||||
return apiRequest<UserProfile>("/user/profile", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export type ApiKeyStatus = {
|
||||
claude: boolean;
|
||||
gemini: boolean;
|
||||
};
|
||||
|
||||
export async function getApiKeyStatus(): Promise<ApiKeyStatus> {
|
||||
return apiRequest<ApiKeyStatus>("/user/api-keys");
|
||||
}
|
||||
|
||||
export async function saveApiKey(
|
||||
provider: keyof ApiKeyStatus,
|
||||
apiKey: string | null,
|
||||
): Promise<ApiKeyStatus> {
|
||||
return apiRequest<ApiKeyStatus>(`/user/api-keys/${provider}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_key: apiKey }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProject(projectId: string): Promise<MikeProject> {
|
||||
return apiRequest<MikeProject>(`/projects/${projectId}`);
|
||||
}
|
||||
|
|
@ -230,9 +277,7 @@ export interface MikeDocumentVersion {
|
|||
display_name: string | null;
|
||||
}
|
||||
|
||||
export async function listDocumentVersions(
|
||||
documentId: string,
|
||||
): Promise<{
|
||||
export async function listDocumentVersions(documentId: string): Promise<{
|
||||
current_version_id: string | null;
|
||||
versions: MikeDocumentVersion[];
|
||||
}> {
|
||||
|
|
@ -321,9 +366,7 @@ export async function getDocumentUrl(
|
|||
documentId: string,
|
||||
versionId?: string | null,
|
||||
): Promise<{ url: string; filename: string; version_id: string | null }> {
|
||||
const qs = versionId
|
||||
? `?version_id=${encodeURIComponent(versionId)}`
|
||||
: "";
|
||||
const qs = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||
return apiRequest(`/single-documents/${documentId}/url${qs}`);
|
||||
}
|
||||
|
||||
|
|
@ -483,9 +526,7 @@ export async function streamProjectChat(payload: {
|
|||
export async function listTabularReviews(
|
||||
projectId?: string,
|
||||
): Promise<TabularReview[]> {
|
||||
const qs = projectId
|
||||
? `?project_id=${encodeURIComponent(projectId)}`
|
||||
: "";
|
||||
const qs = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
|
||||
return apiRequest<TabularReview[]>(`/tabular-review${qs}`);
|
||||
}
|
||||
|
||||
|
|
@ -794,9 +835,7 @@ export async function shareWorkflow(
|
|||
});
|
||||
}
|
||||
|
||||
export async function listWorkflowShares(
|
||||
workflowId: string,
|
||||
): Promise<
|
||||
export async function listWorkflowShares(workflowId: string): Promise<
|
||||
{
|
||||
id: string;
|
||||
shared_with_email: string;
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export default function LoginPage() {
|
|||
</div>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Login Form */}
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-8">
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-8 mb-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-left text-2xl font-serif">
|
||||
Log In
|
||||
|
|
@ -119,6 +119,12 @@ export default function LoginPage() {
|
|||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
<p className="text-center text-xs text-gray-500 leading-relaxed px-2">
|
||||
Mike hosted on MikeOSS.com is currently a demo service.
|
||||
Please do not upload, submit, or store sensitive,
|
||||
confidential, privileged, client, or personally
|
||||
identifiable documents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
160
frontend/src/app/privacy/page.tsx
Normal file
160
frontend/src/app/privacy/page.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<main className="w-full px-6 py-6 md:py-10">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl font-medium font-eb-garamond mb-8">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">
|
||||
1. Introduction
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
Mike ("we," "our," or "us") is committed to protecting
|
||||
your privacy. This Privacy Policy explains how we
|
||||
collect, use, disclose, and safeguard your information
|
||||
when you use our legal research service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">
|
||||
2. Information We Collect
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed mb-2">
|
||||
We collect information that you provide directly to us,
|
||||
including:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-3 text-sm text-gray-700 space-y-1">
|
||||
<li>Email address and account credentials</li>
|
||||
<li>Search queries and research history</li>
|
||||
<li>Chat conversations with our AI assistant</li>
|
||||
<li>Usage data and preferences within the service</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">
|
||||
3. How We Use Your Information
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed mb-2">
|
||||
We use the information we collect to:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-3 text-sm text-gray-700 space-y-1">
|
||||
<li>Provide, maintain, and improve our services</li>
|
||||
<li>Process your requests and transactions</li>
|
||||
<li>Send you technical notices and support messages</li>
|
||||
<li>Respond to your comments and questions</li>
|
||||
<li>Develop new features and improve our AI models</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">
|
||||
4. Information Sharing and Disclosure
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed mb-2">
|
||||
We do not sell your personal information. We may share
|
||||
your information only in the following circumstances:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-3 text-sm text-gray-700 space-y-1">
|
||||
<li>With your consent</li>
|
||||
<li>
|
||||
To comply with legal obligations or court orders
|
||||
</li>
|
||||
<li>
|
||||
To protect our rights, privacy, safety, or property
|
||||
</li>
|
||||
<li>
|
||||
With service providers who assist in our operations
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">
|
||||
5. Data Security
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
We implement appropriate technical and organizational
|
||||
measures to protect your personal information. However,
|
||||
no method of transmission over the Internet or
|
||||
electronic storage is 100% secure, and we cannot
|
||||
guarantee absolute security.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">
|
||||
6. Data Retention
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
We retain your personal information for as long as
|
||||
necessary to provide our services and fulfill the
|
||||
purposes outlined in this Privacy Policy, unless a
|
||||
longer retention period is required by law.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">7. Your Rights</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed mb-2">
|
||||
You have the right to:
|
||||
</p>
|
||||
<ul className="list-disc pl-6 mb-3 text-sm text-gray-700 space-y-1">
|
||||
<li>Access and receive a copy of your data</li>
|
||||
<li>Correct inaccurate or incomplete data</li>
|
||||
<li>Request deletion of your data</li>
|
||||
<li>Object to or restrict data processing</li>
|
||||
<li>Data portability</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">
|
||||
8. Cookies and Tracking Technologies
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
We use cookies and similar tracking technologies to
|
||||
collect and track information about your usage of our
|
||||
service. You can control cookies through your browser
|
||||
settings.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">
|
||||
9. Children's Privacy
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
Our service is not intended for children under 13 years
|
||||
of age. We do not knowingly collect personal information
|
||||
from children under 13.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">
|
||||
10. Changes to This Privacy Policy
|
||||
</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
We may update this Privacy Policy from time to time. We
|
||||
will notify you of any changes by posting the new
|
||||
Privacy Policy on this page and updating the "Last
|
||||
updated" date.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6">
|
||||
<h2 className="text-xl font-medium mb-3">11. Contact Us</h2>
|
||||
<p className="text-sm text-gray-700 leading-relaxed">
|
||||
If you have any questions about this Privacy Policy,
|
||||
please contact us at team@mikeoss.com.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import Link from "next/link";
|
|||
import { SiteLogo } from "@/components/site-logo";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { updateUserProfile } from "@/app/lib/mikeApi";
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -59,19 +60,12 @@ export default function SignupPage() {
|
|||
const trimmedName = name.trim();
|
||||
const trimmedOrg = organisation.trim();
|
||||
if (trimmedName || trimmedOrg) {
|
||||
// The handle_new_user DB trigger creates the
|
||||
// user_profiles row synchronously on auth.users insert,
|
||||
// so we UPDATE rather than upsert — RLS permits update
|
||||
// of the user's own row but blocks self-INSERT.
|
||||
const { error: profileError } = await supabase
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
...(trimmedName && { display_name: trimmedName }),
|
||||
try {
|
||||
await updateUserProfile({
|
||||
...(trimmedName && { displayName: trimmedName }),
|
||||
...(trimmedOrg && { organisation: trimmedOrg }),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", data.session.user.id);
|
||||
if (profileError) {
|
||||
});
|
||||
} catch (profileError) {
|
||||
console.error(
|
||||
"[signup] failed to persist profile fields",
|
||||
profileError,
|
||||
|
|
@ -83,8 +77,12 @@ export default function SignupPage() {
|
|||
setTimeout(() => {
|
||||
router.push("/assistant");
|
||||
}, 2000);
|
||||
} catch (error: any) {
|
||||
setError(error.message || "An error occurred during signup");
|
||||
} catch (error: unknown) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An error occurred during signup",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -121,7 +119,7 @@ export default function SignupPage() {
|
|||
<SiteLogo size="md" className="md:text-4xl" asLink />
|
||||
</div>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-8">
|
||||
<div className="bg-white border border-gray-200 rounded-2xl p-8 mb-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-left text-2xl font-serif">
|
||||
Create Account
|
||||
|
|
@ -275,6 +273,12 @@ export default function SignupPage() {
|
|||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-xs text-gray-500 leading-relaxed px-2">
|
||||
Mike hosted on MikeOSS.com is currently a demo service.
|
||||
Please do not upload, submit, or store sensitive,
|
||||
confidential, privileged, client, or personally identifiable
|
||||
documents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
273
frontend/src/app/support/page.tsx
Normal file
273
frontend/src/app/support/page.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Send, CheckCircle } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
type FeedbackType = "bug" | "feature" | "question" | "other";
|
||||
|
||||
export default function SupportPage() {
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, authLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [authLoading, isAuthenticated, router]);
|
||||
const [feedbackType, setFeedbackType] = useState<FeedbackType>("question");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [link, setLink] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const feedbackTypes: {
|
||||
value: FeedbackType;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "bug",
|
||||
label: "Bug Report",
|
||||
description: "Report something that isn't working",
|
||||
},
|
||||
{
|
||||
value: "feature",
|
||||
label: "Feature Request",
|
||||
description: "Suggest a new feature or improvement",
|
||||
},
|
||||
{
|
||||
value: "question",
|
||||
label: "Question",
|
||||
description: "Ask a question about using Mike",
|
||||
},
|
||||
{
|
||||
value: "other",
|
||||
label: "Other",
|
||||
description: "General feedback or other inquiries",
|
||||
},
|
||||
];
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/support", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: feedbackType,
|
||||
subject,
|
||||
message,
|
||||
email: user?.email,
|
||||
link,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to submit feedback");
|
||||
}
|
||||
|
||||
setIsSubmitted(true);
|
||||
} catch (err) {
|
||||
console.error("Error submitting feedback:", err);
|
||||
setError("Failed to submit your feedback. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isSubmitted) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full bg-white rounded-xl text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="h-16 w-16 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 mb-2">
|
||||
Thank you for helping us improve.
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
We will get in touch with you soon via email.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push("/")}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
Back to Home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col px-6 h-full">
|
||||
<div className="w-full max-w-4xl m-auto flex flex-col h-full">
|
||||
{/* Fixed Header Section */}
|
||||
<div className="flex-shrink-0 pt-6 md:pt-10 pb-0">
|
||||
<div className="mb-5">
|
||||
<h1 className="text-4xl font-medium font-eb-garamond text-gray-900 mb-3">
|
||||
Support
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Container */}
|
||||
<div className="flex-1 overflow-y-auto pb-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Feedback Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
What can we help you with?
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{feedbackTypes.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setFeedbackType(type.value)
|
||||
}
|
||||
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||
feedbackType === type.value
|
||||
? "border-blue-600 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`font-medium ${
|
||||
feedbackType === type.value
|
||||
? "text-blue-700"
|
||||
: "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{type.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link (for bugs) */}
|
||||
{feedbackType === "bug" && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="link"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Link to issue (optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="link"
|
||||
value={link}
|
||||
onChange={(e) =>
|
||||
setLink(e.target.value)
|
||||
}
|
||||
placeholder="https://mikeoss.com/..."
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
If the bug is in a chat, mouseover the
|
||||
chat in the sidebar, click the dots,
|
||||
then click share and paste the link
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="subject"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Please describe your question, issue, or suggestion in detail..."
|
||||
rows={5}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all resize-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email Display (if logged in) */}
|
||||
{user?.email && (
|
||||
<div className="text-sm text-gray-500">
|
||||
We'll respond to:{" "}
|
||||
<span className="font-medium">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
!subject.trim() ||
|
||||
!message.trim()
|
||||
}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="h-4 w-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Sending...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4" />
|
||||
<span>Submit</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
frontend/src/app/terms/page.tsx
Normal file
202
frontend/src/app/terms/page.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"use client";
|
||||
|
||||
const lastUpdated = "May 2, 2026";
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: "1. Acceptance of Terms",
|
||||
body: [
|
||||
"Welcome to Mike. These Terms of Service are a legally binding agreement between you and Mike regarding your access to and use of our website, hosted application, open-source software, APIs, and related services.",
|
||||
"By creating an account, clicking to accept these Terms, or using the Service, you acknowledge that you have read, understood, and agree to be bound by these Terms and our Privacy Policy. If you do not agree, you may not use the Service.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "2. Service Overview",
|
||||
body: [
|
||||
"Mike provides legal AI workflow tools, including document upload, project workspaces, document chat, citations, tabular review, reusable workflows, and document drafting or editing features.",
|
||||
"Mike hosted on MikeOSS.com is currently provided as a demo service for evaluation and testing purposes only. You should not upload, submit, transmit, or store sensitive, confidential, privileged, proprietary, personally identifiable, client, or otherwise restricted information through the Service. Use the Service only with non-sensitive materials and at your own risk.",
|
||||
"The Service may connect to third-party large language model providers, hosting providers, authentication services, storage services, and payment or infrastructure providers. We may add, remove, suspend, or modify features or third-party integrations at any time.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "3. Eligibility and Authority",
|
||||
body: [
|
||||
"You must be at least 13 years old to use the Service. If you are under 18, you must have permission from a parent or legal guardian.",
|
||||
"If you use the Service on behalf of a company, law firm, organization, or other entity, you represent that you have authority to bind that entity to these Terms.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "4. Accounts and Security",
|
||||
body: [
|
||||
"You may need an account to access most features. You agree to provide accurate account information and to keep it up to date.",
|
||||
"You are responsible for maintaining the confidentiality of your account credentials and for all activity under your account. If you believe your account is compromised, contact us promptly at team@mikeoss.com.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "5. Fees, Credits, and Third-Party Costs",
|
||||
body: [
|
||||
"Some features may be free, metered, usage-limited, or paid. We may introduce or change fees, plans, credits, quotas, or usage limits with notice where required by law.",
|
||||
"If you connect your own third-party AI provider API keys, you are responsible for any charges, usage limits, provider terms, or account restrictions imposed by those providers.",
|
||||
"Unless otherwise stated at the time of purchase, fees are non-refundable except where required by law.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "6. User Content and AI Outputs",
|
||||
body: [
|
||||
"You may submit documents, prompts, text, files, data, and other materials to the Service (\"Input\") and receive AI-generated or system-generated responses, summaries, extractions, drafts, edits, citations, or other content (\"Output\"). Input and Output are collectively \"User Content.\"",
|
||||
"As between you and Mike, you retain any rights you have in your Input. Subject to applicable law and third-party provider terms, you are responsible for evaluating and using Output.",
|
||||
"You grant Mike a limited license to host, store, process, transmit, display, and otherwise use User Content as necessary to provide, secure, troubleshoot, improve, and support the Service.",
|
||||
"You represent that you have all rights and permissions necessary to submit Input to the Service and that your Input and use of the Service will not violate law, third-party rights, confidentiality duties, court orders, professional obligations, or applicable provider terms.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "7. Legal and Professional Responsibility",
|
||||
body: [
|
||||
"Mike is a software tool. It does not provide legal, financial, tax, regulatory, compliance, or professional advice, and it does not create an attorney-client relationship.",
|
||||
"AI systems can produce inaccurate, incomplete, outdated, or misleading Output. You are solely responsible for reviewing, verifying, and exercising professional judgment before relying on any Output or using it in client work, filings, transactions, negotiations, or legal advice.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "8. Third-Party AI Models and Services",
|
||||
body: [
|
||||
"The Service may route Input to third-party AI models or infrastructure providers selected by you, configured by your account, or made available through the Service.",
|
||||
"Your use of third-party models or services may be subject to additional terms, policies, data practices, retention settings, training settings, and usage restrictions. We are not responsible for third-party services, model availability, model behavior, pricing, outages, or provider terms.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "9. Prohibited Conduct",
|
||||
body: [
|
||||
"You agree not to use the Service for unlawful, harmful, infringing, deceptive, abusive, or security-compromising activity.",
|
||||
"You may not attempt to gain unauthorized access to the Service or any account, interfere with the Service, upload malware, scrape or copy the Service except as permitted by law or applicable open-source licenses, bypass usage limits, misrepresent your identity, or use the Service in violation of any third-party AI provider terms.",
|
||||
"You may not submit Input that you do not have the right to use, that violates confidentiality or privacy obligations, or that infringes intellectual property or other third-party rights.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "10. Open-Source Software and Ownership",
|
||||
body: [
|
||||
"Certain Mike software may be made available under open-source licenses. Your use, copying, modification, and distribution of that open-source software is governed by the applicable open-source license, not these Terms.",
|
||||
"The hosted Service, website, brand, design, trade names, hosted infrastructure, documentation, and non-open-source elements are owned by Mike or its licensors and are protected by intellectual property and other laws.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "11. Feedback",
|
||||
body: [
|
||||
"If you provide comments, suggestions, ideas, or feedback, you grant us a perpetual, irrevocable, worldwide, royalty-free license to use that feedback for any purpose without obligation to compensate you.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "12. Confidentiality",
|
||||
body: [
|
||||
"Each party may receive non-public information from the other in connection with the Service. The receiving party will use reasonable care to protect confidential information and will use it only for purposes related to the Service, except where disclosure is required by law or authorized by the disclosing party.",
|
||||
"Confidential information does not include information that is public through no fault of the receiving party, already known without a confidentiality duty, lawfully received from a third party, independently developed, or submitted as feedback.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "13. Privacy and Data Protection",
|
||||
body: [
|
||||
"Please review our Privacy Policy for information about how we collect, use, store, and disclose personal information. The Privacy Policy is incorporated into these Terms.",
|
||||
"If you use the Service on behalf of an organization and require a data processing agreement, contact us at team@mikeoss.com.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "14. Suspension and Termination",
|
||||
body: [
|
||||
"You may stop using the Service at any time. We may suspend or terminate your access to the Service if you violate these Terms, create risk for the Service or other users, or if we discontinue the Service or any material feature.",
|
||||
"Upon termination, your right to use the Service ends, but provisions that by their nature should survive will survive, including provisions about User Content, ownership, confidentiality, disclaimers, limitations of liability, indemnity, and dispute resolution.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "15. Disclaimers",
|
||||
body: [
|
||||
"THE SERVICE, OUTPUT, MATERIALS, AND ALL CONTENT AVAILABLE THROUGH THE SERVICE ARE PROVIDED \"AS IS\" AND \"AS AVAILABLE\" WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, OR STATUTORY.",
|
||||
"TO THE MAXIMUM EXTENT PERMITTED BY LAW, WE DISCLAIM ALL WARRANTIES, INCLUDING IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, QUIET ENJOYMENT, NON-INFRINGEMENT, ACCURACY, AVAILABILITY, SECURITY, AND RELIABILITY.",
|
||||
"WE DO NOT WARRANT THAT THE SERVICE OR OUTPUT WILL BE UNINTERRUPTED, ERROR-FREE, SECURE, CURRENT, COMPLETE, OR SUITABLE FOR ANY PARTICULAR LEGAL OR PROFESSIONAL USE.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "16. Limitation of Liability",
|
||||
body: [
|
||||
"TO THE MAXIMUM EXTENT PERMITTED BY LAW, MIKE AND ITS AFFILIATES, OFFICERS, EMPLOYEES, CONTRACTORS, AGENTS, SUPPLIERS, AND LICENSORS WILL NOT BE LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, OR FOR LOST PROFITS, LOST REVENUE, LOST DATA, LOSS OF GOODWILL, BUSINESS INTERRUPTION, OR SUBSTITUTE SERVICES.",
|
||||
"THE SERVICE IS PROVIDED FREE OF CHARGE. TO THE MAXIMUM EXTENT PERMITTED BY LAW, MIKE WILL NOT BE LIABLE FOR ANY DAMAGES ARISING OUT OF OR RELATING TO THE SERVICE OR THESE TERMS.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "17. Indemnity",
|
||||
body: [
|
||||
"You will defend, indemnify, and hold harmless Mike and its affiliates, officers, employees, contractors, agents, suppliers, and licensors from and against claims, liabilities, damages, losses, and expenses, including reasonable attorneys' fees, arising from your use of the Service, your User Content, your violation of these Terms, your violation of law, or your violation of third-party rights.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "18. Changes to These Terms",
|
||||
body: [
|
||||
"We may modify these Terms from time to time. If changes materially affect your rights or obligations, we will provide reasonable notice, such as by posting the updated Terms or sending an email or in-product notice.",
|
||||
"Your continued use of the Service after the effective date of updated Terms means you accept the updated Terms. If you do not agree, you must stop using the Service.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "19. Governing Law and Dispute Resolution",
|
||||
body: [
|
||||
"These Terms are governed by the laws of the State of New York, without regard to conflict of law principles, unless applicable law requires otherwise.",
|
||||
"Before filing a claim, each party agrees to try to resolve the dispute informally by contacting the other party. You may contact us at team@mikeoss.com. If the dispute is not resolved within 30 days, either party may pursue available remedies in a court of competent jurisdiction.",
|
||||
"You and Mike agree that claims must be brought only in an individual capacity and not as a plaintiff or class member in any class, collective, consolidated, private attorney general, or representative proceeding, to the maximum extent permitted by law.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "20. Electronic Communications",
|
||||
body: [
|
||||
"By using the Service, you consent to receive communications from us electronically. Electronic communications may include notices, account messages, product updates, and legal disclosures. You agree that electronic communications satisfy any legal requirement that such communications be in writing.",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "21. Contact",
|
||||
body: [
|
||||
"If you have questions about these Terms, contact us at team@mikeoss.com.",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<main className="w-full px-6 py-6 md:py-10">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl font-medium font-eb-garamond mb-3">
|
||||
Terms of Service
|
||||
</h1>
|
||||
<p className="mb-6 text-sm text-gray-500">
|
||||
Last Updated: {lastUpdated}
|
||||
</p>
|
||||
<div className="mb-8 rounded-md border border-amber-200 bg-amber-50 p-4">
|
||||
<p className="text-sm font-medium text-amber-900 mb-1">
|
||||
Demo service notice
|
||||
</p>
|
||||
<p className="text-sm text-amber-800 leading-relaxed">
|
||||
Mike hosted on MikeOSS.com is currently provided as a
|
||||
demo service. Do not upload, submit, or store
|
||||
sensitive, confidential, privileged, proprietary,
|
||||
client, or personally identifiable documents or
|
||||
information through the Service.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-7">
|
||||
{sections.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h2 className="text-xl font-medium mb-3">
|
||||
{section.title}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{section.body.map((paragraph) => (
|
||||
<p
|
||||
key={paragraph}
|
||||
className="text-sm text-gray-700 leading-relaxed"
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,17 +28,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const [authLoading, setAuthLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const ensureProfile = async (accessToken: string) => {
|
||||
const apiBase =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001";
|
||||
await fetch(`${apiBase}/user/profile`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}).catch((e) => {
|
||||
console.log(e);
|
||||
});
|
||||
};
|
||||
|
||||
const checkUser = async () => {
|
||||
const {
|
||||
data: { session },
|
||||
|
|
@ -49,7 +38,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
id: session.user.id,
|
||||
email: session.user.email || "",
|
||||
});
|
||||
ensureProfile(session.access_token);
|
||||
}
|
||||
setAuthLoading(false);
|
||||
};
|
||||
|
|
@ -64,7 +52,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
id: session.user.id,
|
||||
email: session.user.email || "",
|
||||
});
|
||||
ensureProfile(session.access_token);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,13 @@ import React, {
|
|||
ReactNode,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import {
|
||||
type UserProfile as ApiUserProfile,
|
||||
getUserProfile,
|
||||
saveApiKey,
|
||||
updateUserProfile,
|
||||
} from "@/app/lib/mikeApi";
|
||||
|
||||
interface UserProfile {
|
||||
displayName: string | null;
|
||||
|
|
@ -44,95 +49,27 @@ const UserProfileContext = createContext<UserProfileContextType | undefined>(
|
|||
undefined,
|
||||
);
|
||||
|
||||
const CONFIGURED_KEY_MARKER = "configured";
|
||||
|
||||
function toProfile(data: ApiUserProfile): UserProfile {
|
||||
const { apiKeyStatus, ...profile } = data;
|
||||
return {
|
||||
...profile,
|
||||
claudeApiKey: apiKeyStatus.claude ? CONFIGURED_KEY_MARKER : null,
|
||||
geminiApiKey: apiKeyStatus.gemini ? CONFIGURED_KEY_MARKER : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function UserProfileProvider({ children }: { children: ReactNode }) {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadProfile = useCallback(async (userId: string) => {
|
||||
const loadProfile = useCallback(async () => {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("user_profiles")
|
||||
.select("*")
|
||||
.eq("user_id", userId)
|
||||
.single();
|
||||
|
||||
// Define credit limit constant
|
||||
const MONTHLY_CREDIT_LIMIT = 999999; // temporarily unlimited
|
||||
|
||||
// Calculate a default future reset date (30 days from now)
|
||||
const futureResetDate = new Date();
|
||||
futureResetDate.setDate(futureResetDate.getDate() + 30);
|
||||
const defaultResetDateStr = futureResetDate.toISOString();
|
||||
|
||||
if (error) {
|
||||
// Set fallback profile data if profile doesn't exist
|
||||
setProfile({
|
||||
displayName: null,
|
||||
organisation: null,
|
||||
messageCreditsUsed: 0,
|
||||
creditsResetDate: defaultResetDateStr,
|
||||
creditsRemaining: MONTHLY_CREDIT_LIMIT,
|
||||
tier: "Free",
|
||||
tabularModel: "gemini-3-flash-preview",
|
||||
claudeApiKey: null,
|
||||
geminiApiKey: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Use fetched data to update profile state
|
||||
if (data) {
|
||||
let creditsUsed = data.message_credits_used;
|
||||
let resetDate = data.credits_reset_date;
|
||||
let creditsRemaining = MONTHLY_CREDIT_LIMIT - creditsUsed;
|
||||
let shouldUpdateDb = false;
|
||||
|
||||
// Check if credits have expired and need reset
|
||||
if (resetDate && new Date() > new Date(resetDate)) {
|
||||
// Calculate new reset date
|
||||
const newResetDate = new Date();
|
||||
newResetDate.setDate(newResetDate.getDate() + 30);
|
||||
resetDate = newResetDate.toISOString();
|
||||
creditsUsed = 0;
|
||||
creditsRemaining = MONTHLY_CREDIT_LIMIT;
|
||||
shouldUpdateDb = true;
|
||||
}
|
||||
|
||||
// 1. Update local state immediately
|
||||
setProfile({
|
||||
displayName: data.display_name,
|
||||
organisation: data.organisation ?? null,
|
||||
messageCreditsUsed: creditsUsed,
|
||||
creditsResetDate: resetDate,
|
||||
creditsRemaining: creditsRemaining,
|
||||
tier: data.tier || "Free",
|
||||
tabularModel:
|
||||
data.tabular_model || "gemini-3-flash-preview",
|
||||
claudeApiKey: data.claude_api_key ?? null,
|
||||
geminiApiKey: data.gemini_api_key ?? null,
|
||||
});
|
||||
|
||||
// 2. Update database in background if needed
|
||||
if (shouldUpdateDb) {
|
||||
supabase
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
message_credits_used: 0,
|
||||
credits_reset_date: resetDate,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", userId)
|
||||
.then(({ error }) => {
|
||||
if (error)
|
||||
console.error(
|
||||
"Failed to auto-reset credits",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
const profileData = await getUserProfile();
|
||||
setProfile(toProfile(profileData));
|
||||
} catch {
|
||||
// Calculate a default future reset date for fallback
|
||||
const futureResetDate = new Date();
|
||||
futureResetDate.setDate(futureResetDate.getDate() + 30);
|
||||
|
|
@ -157,7 +94,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
setLoading(true);
|
||||
loadProfile(user.id);
|
||||
loadProfile();
|
||||
} else {
|
||||
setProfile(null);
|
||||
setLoading(false);
|
||||
|
|
@ -171,19 +108,10 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
display_name: displayName,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", user.id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setProfile((prev) => (prev ? { ...prev, displayName } : null));
|
||||
const updated = await updateUserProfile({ displayName });
|
||||
setProfile((prev) =>
|
||||
prev ? { ...prev, ...toProfile(updated) } : null,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -196,16 +124,9 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
async (organisation: string): Promise<boolean> => {
|
||||
if (!user) return false;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
organisation,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", user.id);
|
||||
if (error) throw error;
|
||||
const updated = await updateUserProfile({ organisation });
|
||||
setProfile((prev) =>
|
||||
prev ? { ...prev, organisation } : null,
|
||||
prev ? { ...prev, ...toProfile(updated) } : null,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
|
|
@ -216,24 +137,15 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
);
|
||||
|
||||
const updateModelPreference = useCallback(
|
||||
async (
|
||||
field: "tabularModel",
|
||||
value: string,
|
||||
): Promise<boolean> => {
|
||||
async (field: "tabularModel", value: string): Promise<boolean> => {
|
||||
if (!user) return false;
|
||||
const dbField = field === "tabularModel" ? "tabular_model" : "";
|
||||
if (!dbField) return false;
|
||||
if (field !== "tabularModel") return false;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
[dbField]: value,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", user.id);
|
||||
if (error) throw error;
|
||||
const updated = await updateUserProfile({
|
||||
tabularModel: value,
|
||||
});
|
||||
setProfile((prev) =>
|
||||
prev ? { ...prev, [field]: value } : null,
|
||||
prev ? { ...prev, ...toProfile(updated) } : null,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
|
|
@ -249,22 +161,20 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
value: string | null,
|
||||
): Promise<boolean> => {
|
||||
if (!user) return false;
|
||||
const dbField =
|
||||
provider === "claude" ? "claude_api_key" : "gemini_api_key";
|
||||
const stateField =
|
||||
provider === "claude" ? "claudeApiKey" : "geminiApiKey";
|
||||
const normalized = value?.trim() ? value.trim() : null;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
[dbField]: normalized,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", user.id);
|
||||
if (error) throw error;
|
||||
await saveApiKey(provider, normalized);
|
||||
setProfile((prev) =>
|
||||
prev ? { ...prev, [stateField]: normalized } : null,
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
[stateField]: normalized
|
||||
? CONFIGURED_KEY_MARKER
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
|
|
@ -276,7 +186,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
const reloadProfile = useCallback(async () => {
|
||||
if (user) {
|
||||
await loadProfile(user.id);
|
||||
await loadProfile();
|
||||
}
|
||||
}, [user, loadProfile]);
|
||||
|
||||
|
|
@ -290,36 +200,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
|||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const newCreditsUsed = profile.messageCreditsUsed + 1;
|
||||
|
||||
const { error } = await supabase
|
||||
.from("user_profiles")
|
||||
.update({
|
||||
message_credits_used: newCreditsUsed,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", user.id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setProfile((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
messageCreditsUsed: newCreditsUsed,
|
||||
creditsRemaining: 999999 - newCreditsUsed, // temporarily unlimited
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}, [user, profile]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { NextRequest } from 'next/server';
|
|||
/**
|
||||
* Extract and validate user from Supabase JWT token
|
||||
* Returns user info if valid, null if invalid or missing
|
||||
*
|
||||
*
|
||||
* @param request NextRequest with Authorization header
|
||||
* @returns User object with email and id, or null
|
||||
*/
|
||||
|
|
@ -13,17 +13,17 @@ export async function getUserFromRequest(request: NextRequest): Promise<{
|
|||
} | null> {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Validate with Supabase
|
||||
const { createClient } = await import('@supabase/supabase-js');
|
||||
const supabase = createClient(
|
||||
|
|
@ -32,7 +32,7 @@ export async function getUserFromRequest(request: NextRequest): Promise<{
|
|||
);
|
||||
|
||||
const { data: { user }, error } = await supabase.auth.getUser(token);
|
||||
|
||||
|
||||
if (error || !user) {
|
||||
console.warn('[Auth] Invalid or expired token:', error?.message);
|
||||
return null;
|
||||
|
|
@ -53,3 +53,4 @@ export async function getUserFromRequest(request: NextRequest): Promise<{
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue