8000 feat(chat): an empty chat offers Import when nothing is in scope by adeelahmad · Pull Request #703 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ listed under [In progress](#in-progress), and what comes next under [Planned](#p
research comes back with a plan to approve, edit or revise, then runs in the background and returns a cited report,
checked against its sources, to keep as a note, hear aloud or turn into a podcast. Regenerating an answer or editing a
question makes a new version beside the old one, and nothing is lost. A thumbs up or down marks an answer good or bad
(pressed again, it's taken back), and agents rate answers the same way over MCP. Each answer keeps a record of what produced it
(pressed again, it's taken back), and agents rate answers the same way over MCP. A new conversation with nothing
imported yet offers Import rather than questions nothing can answer. Each answer keeps a record of what produced it
(the model, its tools, how long it took and what it cost), opened from its header. Copy an answer as Markdown or plain
text, a question or a code block, and export a conversation as Markdown, PDF or JSON, its citations as footnotes that
link back to the moment in Lens. It reads answers aloud with a speech provider, macOS `say`, or Kokoro (54
Expand Down
4 changes: 4 additions & 0 deletions changelog.d/empty-chat-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- **An empty chat names the next step.** A new conversation with nothing in its scope yet (nothing imported, or none
in the namespaces picked) says chat has nothing to answer from and offers Import assets, plus Change scope when
namespaces are picked, instead of suggesting questions nothing can answer. Someone who can't import is told an
editor or admin can ([Chat](docs/assistant.md#chat)). Nothing needs upgrading.
5 changes: 5 additions & 0 deletions docs/assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ Conversations belong to one person and can be scoped to namespaces, assets, coll
collection in the scope is read each time the assistant answers, so it draws on the collection's assets as they are
then.

- **Before the first question:** a new conversation suggests questions from what is in its scope. With nothing in
the scope yet (no asset you can read, or none in the namespaces picked), it says chat has nothing to answer from
and offers **Import assets**, plus **Change scope** when namespaces are picked; someone who can't import anywhere
is told an editor or admin can. Agents find the same out from `GET /api/v1/recordings` or the MCP tool
`list_recordings` coming back empty.
- **Retrieval:** the question's keywords go through the word index (English stemming) and, when search by meaning is
set up, passages about the question in other words are found too
([Search by meaning](processing.md#search-by-meaning)); the two are ranked together by reciprocal rank, limited to
Expand Down
1 change: 1 addition & 0 deletions docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ request, to another check that proves the same thing; loosening a requirement or
| On a Docker install, Settings starts and stops the sandbox's gateway next to Lens, and says why a machine can't run it | [test_sandbox.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_sandbox.py) `test_the_gateway_is_started_and_stopped_from_settings`, `test_a_machine_that_cant_run_the_gateway_says_why`; [sandbox-gateway.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/sandbox-gateway.test.ts) `says what the machine is doing, and whether Lens reaches the gateway` |
| Deep research: a goal gets a plan to approve, runs in the background and comes back as a cited report, saved as a note or a podcast episode | [test_research.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_research.py) `test_a_goal_gets_a_plan_that_waits_for_approval_and_can_be_revised`, `test_an_approved_plan_runs_and_its_report_comes_back_checked`, `test_a_report_becomes_a_note_and_a_podcast`; [chat-research.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-research.test.tsx) `approves the plan with Enter, sending the edits`, `saves it as a note, asking where when it could go in several namespaces`, `turns the next message into a research goal, and says when it can't`; [journeys.mjs](https://github.com/adeelahmad/lense/blob/main/ci/clicks/journeys.mjs) `chat-research`; [baseline.json](https://github.com/adeelahmad/lense/blob/main/ci/clicks/baseline.json) `chat-research` ≤ 4 clicks |
| Regenerating an answer or editing a question makes a new version beside the old one | [test_chat_versions.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_chat_versions.py) `test_regenerate_edit_switch_and_go_on`; [chat-versions.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-versions.test.tsx) `regenerating an answer`, `the version switch`; [journeys.mjs](https://github.com/adeelahmad/lense/blob/main/ci/clicks/journeys.mjs) `chat-regenerate`; [baseline.json](https://github.com/adeelahmad/lense/blob/main/ci/clicks/baseline.json) `chat-regenerate` ≤ 4 clicks |
| A new conversation with nothing in its scope offers Import, and Change scope when namespaces are picked, instead of questions nothing can answer | [chat-empty.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-empty.test.tsx) `with nothing imported, offers Import instead of questions nothing can answer`, `with an empty namespace in scope, offers Import and a wider scope`, `someone who can't import is told who can` |
| A thumbs up or down on an answer is saved, shown when the conversation opens, carried in its JSON export and given by agents over MCP | [test_chat_ratings.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_chat_ratings.py) `test_rating_an_answer_is_saved_shown_and_exported`, `test_someone_elses_answer_cant_be_rated`, `test_agents_rate_answers_over_mcp`; [chat-rating.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-rating.test.tsx) `thumbs up saves the rating, and pressing it again takes it back` |
| Each answer keeps a record of what produced it: the model, its tools, how long it took and what it cost | [test_chat_records.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_chat_records.py) `test_an_answer_records_what_produced_it`, `test_a_regenerated_answer_has_its_own_record`; [chat-details.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-details.test.tsx) `says what produced it: model and host, scope, tools, use, tokens, cost, time and how it ended` |
| A conversation exported as Markdown, PDF or JSON, its citations as footnotes linking back to Lens, by people and by agents over MCP | [test_chat_export.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_chat_export.py) `test_markdown`, `test_json`, `test_pdf_is_printed_by_what_the_server_has`, `test_agents_read_and_export_over_mcp`; [chat-copy-export.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/chat-copy-export.test.tsx) `downloads what the server makes, named as the server names it`, `prints in the browser when the server can't make the PDF` |
Expand Down
60 changes: 60 additions & 0 deletions nextjs-frontend/__tests__/chat-empty.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";

import { EmptyChat, nothingInScope } from "@/components/chat/empty";
import { TooltipProvider } from "@/components/ui/tooltip";

const props = {
scope: {},
suggestions: ["What topics came up most this month?"],
onAsk: jest.fn(),
onChangeScope: jest.fn(),
noProvider: false,
admin: false,
};

const show = (p: Partial<React.ComponentProps<typeof EmptyChat>>) =>
render(
<TooltipProvider>
<EmptyChat {...props} {...p} />
</TooltipProvider>,
);

describe("empty chat names the next step", () => {
it("knows when nothing in scope can answer, and waits for the list", () => {
const rows = [{ namespace: "podcasts" }];
expect(nothingInScope(undefined, {})).toBe(false);
expect(nothingInScope([], {})).toBe(true);
expect(nothingInScope(rows, {})).toBe(false);
expect(nothingInScope(rows, { namespaces: ["podcasts"] })).toBe(false);
expect(nothingInScope(rows, { namespaces: ["family"] })).toBe(true);
});

it("with nothing imported, offers Import instead of questions nothing can answer", () => {
show({ nothing: true, canImport: true });
expect(screen.getByRole("heading", { name: "Nothing to answer from yet" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Import assets/ })).toHaveAttribute("href", "/import");
expect(screen.queryByRole("list", { name: "Suggested questions" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Change scope" })).not.toBeInTheDocument();
});

it("with an empty namespace in scope, offers Import and a wider scope", () => {
const >
show({ nothing: true, canImport: true, scope: { namespaces: ["family"] }, onChangeScope });
expect(screen.getByRole("heading", { name: /Nothing in .*family.* yet/ })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Change scope" }));
expect(onChangeScope).toHaveBeenCalled();
});

it("someone who can't import is told who can", () => {
show({ nothing: true, canImport: false });
expect(screen.queryByRole("link", { name: /Import assets/ })).not.toBeInTheDocument();
expect(screen.getByText(/ask one to add you/)).toBeInTheDocument();
});

it("with assets, suggests questions as before", () => {
show({ nothing: false });
expect(screen.getByRole("heading", { name: "Ask about your archive" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "What topics came up most this month?" })).toBeInTheDocument();
});
});
9 changes: 7 additions & 2 deletions nextjs-frontend/components/chat/chat-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { shortTitle } from "@/components/chat/cite";
import { Composer, ScopeBar } from "@/components/chat/composer";
import { ConversationList } from "@/components/chat/conversations";
import { refetchFresh, useApprovals, useChat, useChats, useLlmStatus, useStopAnswer } from "@/components/chat/data";
import { EmptyChat } from "@/components/chat/empty";
import { EmptyChat, nothingInScope } from "@/components/chat/empty";
import { ExportMenu, useChatExport } from "@/components/chat/export";
import { NamespaceOffer, spokenPick } from "@/components/chat/namespace-offer";
import { ReportActions, ResearchCard } from "@/components/chat/research";
Expand Down Expand Up @@ -95,7 +95,7 @@ export function ChatApp() {
const qc = useQueryClient();
const toast = useToast();
const { data: session } = useSession();
const { namespace: topNs, admin } = useArchive();
const { namespace: topNs, admin, namespaces: readable, can } = useArchive();
const activeId = params?.id && /^\d+$/.test(params.id) ? Number(params.id) : null;

const chats = useChats();
Expand Down Expand Up @@ -617,6 +617,9 @@ export function ChatApp() {
if (latest) out.push(`Summarise ${shortTitle(latest.title, 40)} in five bullets`);
return out;
}, [dir.speakers, index.data, scope.namespaces]);
// nothing imported in scope yet: the next step is Import, not a question nothing can answer
const nothing = nothingInScope(index.data, scope);
const canImport = admin || readable.some((n) => can("editor", n.name));

// the model that answers here: the conversation's choice (or the one picked before it started), else the configured one
const chosen = activeId != null ? (chat.data?.model ?? null) : draftModel;
Expand Down Expand Up @@ -781,6 +784,8 @@ export function ChatApp() {
=> setScopeOpenTick((t) => t + 1)}
noProvider={noProvider}
admin={admin}
nothing={nothing}
canImport={canImport}
/>
)}
{items.map((it) => {
Expand Down
47 changes: 46 additions & 1 deletion nextjs-frontend/components/chat/empty.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
"use client";

import { Settings } from "lucide-react";
import { Settings, Upload } from "lucide-react";
import Link from "next/link";

import { scopeWords, type Scope } from "@/components/chat/scope";
import { Button } from "@/components/ui/button";

/** Whether no asset you can read is in the scope (false while the list loads): chat has nothing to answer from. */
export function nothingInScope(rows: { namespace?: string | null }[] | undefined, scope: Scope): boolean {
if (!rows) return false;
return !rows.some(
(r) => !scope.namespaces?.length || (r.namespace != null && scope.namespaces.includes(r.namespace)),
);
}

/** CH2: before the first question — what chat answers from, suggested questions, or why it can't answer yet. */
export function EmptyChat({
scope,
Exp BD0 and All @@ -14,13 +22,19 @@ export function EmptyChat({
onChangeScope,
noProvider,
admin,
nothing = false,
canImport = false,
}: {
scope: Scope;
suggestions: string[];
onAsk: (q: string) => void;
onChangeScope: () => void;
noProvider: boolean;
admin: boolean;
/** No asset in scope yet: chat has nothing to answer from, so the next step is importing. */
nothing?: boolean;
/** You may import into at least one namespace (an editor or an admin). */
canImport?: boolean;
}) {
if (noProvider)
return (
Expand Down Expand Up @@ -55,6 +69,37 @@ export function EmptyChat({
</div>
</div>
);
if (nothing) {
const scoped = Boolean(scope.namespaces?.length);
return (
<div className="flex max-w-[420px] flex-col gap-4 rounded-lg border border-border bg-surface p-4">
<div className="flex flex-col gap-1.5">
<h2 className="text-[17px] font-bold text-fg">
{scoped ? `Nothing in ${scopeWords(scope)} yet` : "Nothing to answer from yet"}
</h2>
<p className="m-0 text-[13.5px] leading-normal text-fg-strong">
Chat answers only from assets you can read, and cites them. Import a recording, a document or a web page,
then ask about it here.
{canImport ? "" : " An editor of a namespace or an admin can import; ask one to add you."}
</p>
</div>
<div className="flex flex-wrap gap-2">
{canImport && (
<Button asChild variant="primary" size="sm">
<Link href="/import">
<Upload /> Import assets
</Link>
</Button>
)}
{scoped && (
<Button variant="secondary" size="sm" >
Change scope
</Button>
)}
</div>
</div>
);
}
const title = scope.namespaces?.length === 1 ? `Ask about ${scope.namespaces[0]}` : "Ask about your archive";
return (
<div className="flex max-w-[420px] flex-col gap-3">
Expand Down
Loading
0