mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Replace persistent /clip with transient /web context
This commit is contained in:
parent
34aca42dc0
commit
10c69cec3f
28 changed files with 385 additions and 807 deletions
|
|
@ -46,7 +46,7 @@ Each panel can keep a separate conversation, so related work can stay open side
|
|||
|
||||
The composer treats Obsidian content as prompt context. It suggests vault files and recent notes for wikilinks, keeps links readable while attaching resolved files, and opens file links from Codex replies back in Obsidian. Use `@active` or `@selection` to include the active file or current Markdown selection without pasting it manually, or enable **Reference active file on send** to reference the current active file with each composer send. When Obsidian Daily Notes or the daily section of Periodic Notes is enabled, `@today`, `@tomorrow`, and `@yesterday` insert wikilinks using that daily-note folder and date format. Paste or drop files to save them in the attachment folder and reference them from the same prompt.
|
||||
|
||||
For context outside the current composer, use `/refer <thread> <message>` to send a message with recent turns from another Codex thread, or `/clip <url> [message]` to save a readable Markdown clipping from a web page and send a wikilink to the saved note.
|
||||
For context outside the current composer, use `/refer <thread> <message>` to send a message with recent turns from another Codex thread, or `/web <url> [message]` to fetch readable web content and attach it to the next turn without saving a note.
|
||||
|
||||
Completions cover slash commands, `$skill-name` skills, Obsidian tags, recent threads, models, reasoning levels, and permission profiles. Use `/help` for the current command list.
|
||||
|
||||
|
|
|
|||
|
|
@ -66,11 +66,11 @@ export const SLASH_COMMANDS = [
|
|||
detail: "Send a message with recent turns from another non-archived thread.",
|
||||
},
|
||||
{
|
||||
command: "/clip",
|
||||
usage: "/clip <url> [message]",
|
||||
command: "/web",
|
||||
usage: "/web <url> [message]",
|
||||
argsKind: "urlAndOptionalMessage",
|
||||
surface: "composition",
|
||||
detail: "Clip a URL into a vault note and send a wikilink reference with an optional message.",
|
||||
detail: "Fetch readable web content as context and send it with an optional message.",
|
||||
},
|
||||
{ command: "/fork", usage: "/fork", argsKind: "none", surface: "panelAction", detail: "Fork the active Codex thread." },
|
||||
{ command: "/btw", usage: "/btw", argsKind: "none", surface: "panelAction", detail: "Open a temporary side chat." },
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { GoalActions } from "../threads/goal-actions";
|
|||
import type { ThreadManagementActions } from "../threads/thread-management-actions";
|
||||
import { type ComposerSubmitActions, type ComposerSubmitActionsHost, submitComposer } from "./composer-submit-actions";
|
||||
import { implementPlan, type PlanImplementationHost } from "./plan-implementation";
|
||||
import type { ClipUrlInput, ThreadReferenceInput } from "./slash-command-execution";
|
||||
import type { ThreadReferenceInput, WebUrlInput } from "./slash-command-execution";
|
||||
import { executeSlashCommandWithState, type SlashCommandExecutorHost } from "./slash-command-executor";
|
||||
import { createTurnSubmissionActions } from "./turn-submission-actions";
|
||||
import type { ChatTurnTransport } from "./turn-transport";
|
||||
|
|
@ -20,7 +20,7 @@ export interface TurnWorkflowContext {
|
|||
connectionAvailable: () => boolean;
|
||||
turnTransport: ChatTurnTransport;
|
||||
referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ThreadReferenceInput | null>;
|
||||
clipUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ClipUrlInput | null>;
|
||||
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<WebUrlInput>;
|
||||
status: {
|
||||
set: (status: string) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
|
|
@ -75,8 +75,19 @@ export interface TurnWorkflowActions {
|
|||
}
|
||||
|
||||
export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: TurnWorkflowRefs): TurnWorkflowActions {
|
||||
const { stateStore, localItemIds, connectionAvailable, turnTransport, referThread, clipUrl, status, runtime, thread, composer, scroll } =
|
||||
context;
|
||||
const {
|
||||
stateStore,
|
||||
localItemIds,
|
||||
connectionAvailable,
|
||||
turnTransport,
|
||||
referThread,
|
||||
readWebUrl,
|
||||
status,
|
||||
runtime,
|
||||
thread,
|
||||
composer,
|
||||
scroll,
|
||||
} = context;
|
||||
const turnSubmission = createTurnSubmissionActions({
|
||||
stateStore,
|
||||
localItemIds,
|
||||
|
|
@ -95,7 +106,7 @@ export function createTurnWorkflowActions(context: TurnWorkflowContext, refs: Tu
|
|||
stateStore,
|
||||
connectionAvailable,
|
||||
referThread,
|
||||
clipUrl,
|
||||
readWebUrl,
|
||||
startNewThread: thread.startNewThread,
|
||||
startThreadForGoal: (objective) => startThreadForGoal(refs.threadStarter, objective),
|
||||
resumeThread: thread.selectThread,
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export interface SlashCommandExecutionContext extends SlashCommandExecutionPorts
|
|||
activeThreadEphemeral: boolean;
|
||||
listedThreads: readonly Thread[];
|
||||
referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ThreadReferenceInput | null>;
|
||||
clipUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ClipUrlInput | null>;
|
||||
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<WebUrlInput>;
|
||||
supportedReasoningEfforts: () => readonly ReasoningEffort[];
|
||||
inputSnapshot?: ComposerInputSnapshot;
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ export interface ThreadReferenceInput {
|
|||
referencedThread: ReferencedThreadMetadata;
|
||||
}
|
||||
|
||||
export interface ClipUrlInput {
|
||||
export interface WebUrlInput {
|
||||
text: string;
|
||||
input: CodexInput;
|
||||
}
|
||||
|
|
@ -145,19 +145,18 @@ export async function executeSlashCommand(
|
|||
if (!reference) return;
|
||||
return { sendText: reference.text, sendInput: reference.input, referencedThread: reference.referencedThread };
|
||||
}
|
||||
case "clip": {
|
||||
case "web": {
|
||||
const parsed = parseUrlAndMessageArgs(args);
|
||||
if (!parsed) {
|
||||
context.addSystemMessage(usageError(command, "requires a URL"));
|
||||
return;
|
||||
}
|
||||
if (!context.inputSnapshot) {
|
||||
context.addSystemMessage("Cannot clip a URL without composer input context.");
|
||||
context.addSystemMessage("Cannot read a web URL without composer input context.");
|
||||
return;
|
||||
}
|
||||
const clipped = await context.clipUrl(parsed.url, parsed.message, context.inputSnapshot);
|
||||
if (!clipped) return;
|
||||
return { sendText: clipped.text, sendInput: clipped.input };
|
||||
const web = await context.readWebUrl(parsed.url, parsed.message, context.inputSnapshot);
|
||||
return { sendText: web.text, sendInput: web.input };
|
||||
}
|
||||
case "fork":
|
||||
if (!context.activeThreadId) {
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import type { SlashCommandName } from "../composer/slash-commands";
|
|||
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import {
|
||||
type ClipUrlInput,
|
||||
executeSlashCommand as runSlashCommand,
|
||||
type SlashCommandExecutionPorts,
|
||||
type SlashCommandExecutionResult,
|
||||
type ThreadReferenceInput,
|
||||
type WebUrlInput,
|
||||
} from "./slash-command-execution";
|
||||
import { submissionStateSnapshot } from "./submission-state";
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ export interface SlashCommandExecutorHost extends SlashCommandExecutionPorts {
|
|||
stateStore: ChatStateStore;
|
||||
connectionAvailable: () => boolean;
|
||||
referThread: (thread: Thread, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ThreadReferenceInput | null>;
|
||||
clipUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<ClipUrlInput | null>;
|
||||
readWebUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot) => Promise<WebUrlInput>;
|
||||
setStatus: (status: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ export async function executeSlashCommandWithState(
|
|||
activeThreadEphemeral: state.activeThreadEphemeral,
|
||||
listedThreads: state.listedThreads,
|
||||
referThread: host.referThread,
|
||||
clipUrl: host.clipUrl,
|
||||
readWebUrl: host.readWebUrl,
|
||||
...(inputSnapshot !== undefined ? { inputSnapshot } : {}),
|
||||
supportedReasoningEfforts: () => supportedReasoningEfforts(host.stateStore.getState()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../../../../domain/markdown/frontmatter";
|
||||
import {
|
||||
vaultMarkdownFilenameFromTemplate,
|
||||
vaultMarkdownFolderPath,
|
||||
vaultMarkdownTemplateDate,
|
||||
vaultMarkdownTemplateTime,
|
||||
} from "../../../../domain/vault/markdown-write-templates";
|
||||
import {
|
||||
ensureVaultFolder,
|
||||
sanitizeVaultPathSegment,
|
||||
uniqueVaultPath,
|
||||
type VaultMarkdownDestination,
|
||||
withVaultWriteLock,
|
||||
} from "../../../../domain/vault/write-paths";
|
||||
|
||||
export interface WebClipSettings {
|
||||
clipFolder: string;
|
||||
clipFilenameTemplate: string;
|
||||
clipTags?: string;
|
||||
}
|
||||
|
||||
export interface WebClipPage {
|
||||
url: string;
|
||||
title: string;
|
||||
content: string;
|
||||
site?: string | null;
|
||||
domain?: string | null;
|
||||
}
|
||||
|
||||
export type WebClipDestination = VaultMarkdownDestination;
|
||||
|
||||
export interface WebClipResult {
|
||||
path: string;
|
||||
wikilink: string;
|
||||
}
|
||||
|
||||
interface TemplateContext {
|
||||
date: string;
|
||||
time: string;
|
||||
title: string;
|
||||
site: string;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CLIP_TITLE = "Untitled clip";
|
||||
|
||||
export async function saveWebClipMarkdown(
|
||||
page: WebClipPage,
|
||||
settings: WebClipSettings,
|
||||
destination: WebClipDestination,
|
||||
now = new Date(),
|
||||
): Promise<WebClipResult> {
|
||||
const context = templateContext(page, now);
|
||||
const normalizePath = (path: string): string => destination.normalizePath(path);
|
||||
const folder = folderPath(settings.clipFolder, normalizePath);
|
||||
const filename = filenameFromTemplate(settings.clipFilenameTemplate, context, normalizePath);
|
||||
return withVaultWriteLock(destination, async () => {
|
||||
await ensureVaultFolder(destination, folder);
|
||||
const path = await uniqueVaultPath(destination, folder, filename);
|
||||
await destination.createMarkdownFile(path, webClipMarkdown(page, settings, context.title, now));
|
||||
return { path, wikilink: `[[${path}]]` };
|
||||
});
|
||||
}
|
||||
|
||||
export function webClipMarkdown(page: WebClipPage, settings: Pick<WebClipSettings, "clipTags">, title?: string, now = new Date()): string {
|
||||
const normalizedTitle = normalizedDisplayTitle(title ?? page.title);
|
||||
const tags = normalizedClipTags(settings.clipTags ?? "");
|
||||
const frontmatter = [
|
||||
"---",
|
||||
`title: ${yamlFrontmatterString(normalizedTitle)}`,
|
||||
`url: ${yamlFrontmatterString(page.url)}`,
|
||||
`created: ${yamlFrontmatterString(now.toISOString())}`,
|
||||
...frontmatterTagsLines(tags),
|
||||
"---",
|
||||
"",
|
||||
];
|
||||
return [...frontmatter, `# ${normalizedTitle}`, "", page.content.trim(), ""].join("\n");
|
||||
}
|
||||
|
||||
function templateContext(page: WebClipPage, now: Date): TemplateContext {
|
||||
const title = sanitizeVaultPathSegment(normalizedDisplayTitle(page.title));
|
||||
return {
|
||||
date: vaultMarkdownTemplateDate(now),
|
||||
time: vaultMarkdownTemplateTime(now),
|
||||
title,
|
||||
site: sanitizeVaultPathSegment(page.site?.trim() || ""),
|
||||
domain: sanitizeVaultPathSegment(page.domain?.trim() || hostnameFromUrl(page.url) || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedDisplayTitle(title: string): string {
|
||||
return title.replace(/\s+/g, " ").trim() || DEFAULT_CLIP_TITLE;
|
||||
}
|
||||
|
||||
function folderPath(value: string, normalizePath: (path: string) => string): string {
|
||||
return vaultMarkdownFolderPath(value, normalizePath, {
|
||||
emptyPathMessage: "Clip folder produced an empty path.",
|
||||
absolutePathMessage: "Clip folder must be relative to the vault.",
|
||||
relativeSegmentMessage: "Clip folder cannot contain relative path segments.",
|
||||
});
|
||||
}
|
||||
|
||||
function filenameFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string {
|
||||
return vaultMarkdownFilenameFromTemplate(template, context, normalizePath, "Clip filename template produced an empty filename.");
|
||||
}
|
||||
|
||||
function normalizedClipTags(input: string): string[] {
|
||||
const tags: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of input.split(",")) {
|
||||
const tag = raw
|
||||
.trim()
|
||||
.replace(/^#/, "")
|
||||
.replace(/^["']|["']$/g, "")
|
||||
.trim();
|
||||
if (!tag || seen.has(tag)) continue;
|
||||
seen.add(tag);
|
||||
tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function frontmatterTagsLines(tags: string[]): string[] {
|
||||
return tags.length > 0 ? [`tags: ${yamlFrontmatterInlineList(tags)}`] : [];
|
||||
}
|
||||
|
||||
function hostnameFromUrl(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import type { ThreadStreamNoticeSection } from "../../domain/thread-stream/items
|
|||
import type { ChatComposerController } from "../../panel/composer-controller";
|
||||
import type { ChatPanelRuntimeProjection } from "../../panel/runtime-status-projection";
|
||||
import type { ChatPanelEnvironment } from "../contracts";
|
||||
import { createVaultWebClipper } from "../obsidian/web-clipper.obsidian";
|
||||
import { createWebContextReader } from "../obsidian/web-context.obsidian";
|
||||
import type { ChatPanelRuntimeSettingsActions } from "./runtime-bundle";
|
||||
import type {
|
||||
ChatPanelGoalActions,
|
||||
|
|
@ -99,17 +99,11 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn
|
|||
connectionAvailable: () => appServer.connectionAvailable(),
|
||||
turnTransport: appServer.turn,
|
||||
referThread: (thread, message, snapshot) => threadReferenceResolver.referThread(thread, message, snapshot),
|
||||
clipUrl: (url, message, snapshot) =>
|
||||
createVaultWebClipper({
|
||||
vault: host.environment.obsidian.app.vault,
|
||||
settings: () => ({
|
||||
clipFolder: host.environment.plugin.settingsRef.settings.clipFolder(),
|
||||
clipFilenameTemplate: host.environment.plugin.settingsRef.settings.clipFilenameTemplate(),
|
||||
clipTags: host.environment.plugin.settingsRef.settings.clipTags(),
|
||||
}),
|
||||
readWebUrl: (url, message, snapshot) =>
|
||||
createWebContextReader({
|
||||
prepareInput: (text, inputSnapshot) => composerController.preparedInput(text, inputSnapshot),
|
||||
viewWindow: host.environment.view.viewWindow,
|
||||
}).clipUrl(url, message, snapshot),
|
||||
}).readUrl(url, message, snapshot),
|
||||
status,
|
||||
runtime: {
|
||||
connectionDiagnosticDetails: runtimeProjection.connectionDiagnosticDetails,
|
||||
|
|
|
|||
|
|
@ -29,9 +29,6 @@ interface ChatPanelSettingsRef {
|
|||
export interface ChatPanelSettingsAccess {
|
||||
referenceActiveNoteOnSend(): boolean;
|
||||
attachmentFolder(): string;
|
||||
clipFolder(): string;
|
||||
clipFilenameTemplate(): string;
|
||||
clipTags(): string;
|
||||
archiveExportEnabled(): boolean;
|
||||
archiveExportSettings(): ArchiveExportSettings;
|
||||
codexPath(): string;
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
import Defuddle from "defuddle/full";
|
||||
import { requestUrl, TFile, type Vault } from "obsidian";
|
||||
|
||||
import type { CodexInput } from "../../../../domain/chat/input";
|
||||
import { codexTextInputWithAttachments } from "../../../../domain/chat/input";
|
||||
import type { CodexPanelSettings } from "../../../../settings/model";
|
||||
import { createObsidianVaultMarkdownDestination } from "../../../../shared/obsidian/vault-write-destination.obsidian";
|
||||
import type { ComposerInputSnapshot } from "../../application/composer/input-snapshot";
|
||||
import { saveWebClipMarkdown } from "../../application/web-clipping/web-clip";
|
||||
import { displayNameForFile } from "./vault-note-links.obsidian";
|
||||
|
||||
export interface WebClipInput {
|
||||
text: string;
|
||||
input: CodexInput;
|
||||
}
|
||||
|
||||
interface VaultWebClipperOptions {
|
||||
vault: Vault;
|
||||
settings: () => Pick<CodexPanelSettings, "clipFolder" | "clipFilenameTemplate" | "clipTags">;
|
||||
prepareInput: (text: string, snapshot: ComposerInputSnapshot) => { text: string; input: CodexInput };
|
||||
viewWindow: () => Window | null;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
interface DefuddleResult {
|
||||
title: string;
|
||||
content: string;
|
||||
site?: string | null;
|
||||
domain?: string | null;
|
||||
}
|
||||
|
||||
type DomParserWindow = Window & { DOMParser: typeof DOMParser };
|
||||
|
||||
export function createVaultWebClipper(options: VaultWebClipperOptions) {
|
||||
return {
|
||||
clipUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot): Promise<WebClipInput | null> =>
|
||||
clipUrlToInput(options, url, message, inputSnapshot),
|
||||
};
|
||||
}
|
||||
|
||||
async function clipUrlToInput(
|
||||
options: VaultWebClipperOptions,
|
||||
url: string,
|
||||
message: string,
|
||||
inputSnapshot: ComposerInputSnapshot,
|
||||
): Promise<WebClipInput | null> {
|
||||
const parsedUrl = normalizedHttpUrl(url);
|
||||
if (!parsedUrl) throw new Error(`Unsupported clip URL: ${url}`);
|
||||
|
||||
const result = await defuddleUrl(options, parsedUrl);
|
||||
const content = result.content.trim();
|
||||
if (!content) throw new Error(`No readable content found for ${parsedUrl}`);
|
||||
|
||||
const destination = createObsidianVaultMarkdownDestination(options.vault);
|
||||
const page = {
|
||||
url: parsedUrl,
|
||||
title: result.title,
|
||||
content,
|
||||
...(result.site !== undefined ? { site: result.site } : {}),
|
||||
...(result.domain !== undefined ? { domain: result.domain } : {}),
|
||||
};
|
||||
const saved = await saveWebClipMarkdown(page, options.settings(), destination, options.now?.() ?? new Date());
|
||||
const file = options.vault.getAbstractFileByPath(saved.path);
|
||||
const name = file instanceof TFile ? displayNameForFile(file) : saved.path;
|
||||
const messageInput = options.prepareInput(message.trim(), inputSnapshot);
|
||||
const text = [saved.wikilink, messageInput.text].filter(Boolean).join(" ");
|
||||
return {
|
||||
text,
|
||||
input: codexTextInputWithAttachments(text, [{ type: "mention", name, path: saved.path }, ...messageInput.input]),
|
||||
};
|
||||
}
|
||||
|
||||
async function defuddleUrl(options: VaultWebClipperOptions, url: string): Promise<DefuddleResult> {
|
||||
const response = await requestUrl({ url, method: "GET" });
|
||||
const html = response.text;
|
||||
const viewWindow = options.viewWindow() as DomParserWindow | null;
|
||||
const Parser = viewWindow?.DOMParser ?? DOMParser;
|
||||
const document = new Parser().parseFromString(html, "text/html");
|
||||
const result = new Defuddle(document, { url, markdown: true, useAsync: false }).parse();
|
||||
return {
|
||||
title: result.title,
|
||||
content: result.content,
|
||||
site: result.site,
|
||||
domain: result.domain,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedHttpUrl(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
84
src/features/chat/host/obsidian/web-context.obsidian.ts
Normal file
84
src/features/chat/host/obsidian/web-context.obsidian.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import Defuddle from "defuddle";
|
||||
import { htmlToMarkdown, requestUrl } from "obsidian";
|
||||
|
||||
import type { CodexInput } from "../../../../domain/chat/input";
|
||||
import { codexTextInputWithAttachments } from "../../../../domain/chat/input";
|
||||
import type { ComposerInputSnapshot } from "../../application/composer/input-snapshot";
|
||||
|
||||
const WEB_CONTEXT_KEY = "codex_panel_web_context";
|
||||
|
||||
export interface WebUrlInput {
|
||||
text: string;
|
||||
input: CodexInput;
|
||||
}
|
||||
|
||||
interface WebContextReaderOptions {
|
||||
prepareInput: (text: string, snapshot: ComposerInputSnapshot) => { text: string; input: CodexInput };
|
||||
viewWindow: () => Window | null;
|
||||
}
|
||||
|
||||
type DomParserWindow = Window & { DOMParser: typeof DOMParser };
|
||||
|
||||
export function createWebContextReader(options: WebContextReaderOptions) {
|
||||
return {
|
||||
readUrl: (url: string, message: string, inputSnapshot: ComposerInputSnapshot): Promise<WebUrlInput> =>
|
||||
readUrlToInput(options, url, message, inputSnapshot),
|
||||
};
|
||||
}
|
||||
|
||||
async function readUrlToInput(
|
||||
options: WebContextReaderOptions,
|
||||
url: string,
|
||||
message: string,
|
||||
inputSnapshot: ComposerInputSnapshot,
|
||||
): Promise<WebUrlInput> {
|
||||
const parsedUrl = normalizedHttpUrl(url);
|
||||
if (!parsedUrl) throw new Error(`Unsupported web URL: ${url}`);
|
||||
|
||||
const page = await fetchWebPage(options, parsedUrl);
|
||||
const content = htmlToMarkdown(page.content).trim();
|
||||
if (!content) throw new Error(`No readable web content found for ${parsedUrl}`);
|
||||
|
||||
const messageInput = options.prepareInput(message.trim(), inputSnapshot);
|
||||
const text = [parsedUrl, messageInput.text].filter(Boolean).join(" ");
|
||||
const context = webContextValue(parsedUrl, page.title, content);
|
||||
return {
|
||||
text,
|
||||
input: codexTextInputWithAttachments(text, [
|
||||
...messageInput.input,
|
||||
{ type: "additionalContext", key: WEB_CONTEXT_KEY, kind: "untrusted", value: context },
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWebPage(options: WebContextReaderOptions, url: string): Promise<{ title: string; content: string }> {
|
||||
const response = await requestUrl({ url, method: "GET", throw: false });
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`Web request failed for ${url} (HTTP ${String(response.status)}).`);
|
||||
}
|
||||
const viewWindow = options.viewWindow() as DomParserWindow | null;
|
||||
const Parser = viewWindow?.DOMParser ?? DOMParser;
|
||||
const document = new Parser().parseFromString(response.text, "text/html");
|
||||
const result = new Defuddle(document, { url, useAsync: false }).parse();
|
||||
return { title: result.title, content: result.content };
|
||||
}
|
||||
|
||||
function webContextValue(url: string, title: string, content: string): string {
|
||||
return [
|
||||
"Web page context for the current user input:",
|
||||
`Source: ${url}`,
|
||||
...(title.trim() ? [`Title: ${title.trim()}`] : []),
|
||||
"",
|
||||
content,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function normalizedHttpUrl(value: string): string | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -140,9 +140,6 @@ export class CodexPanelRuntime implements AppServerClientAccess {
|
|||
return {
|
||||
referenceActiveNoteOnSend: () => this.options.settingsRef.settings.referenceActiveNoteOnSend,
|
||||
attachmentFolder: () => this.options.settingsRef.settings.attachmentFolder,
|
||||
clipFolder: () => this.options.settingsRef.settings.clipFolder,
|
||||
clipFilenameTemplate: () => this.options.settingsRef.settings.clipFilenameTemplate,
|
||||
clipTags: () => this.options.settingsRef.settings.clipTags,
|
||||
archiveExportEnabled: () => this.options.settingsRef.settings.archiveExportEnabled,
|
||||
archiveExportSettings: () => ({
|
||||
archiveExportFolderTemplate: this.options.settingsRef.settings.archiveExportFolderTemplate,
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@ export interface CodexPanelSettings {
|
|||
scrollThreadFromComposerEdges: boolean;
|
||||
referenceActiveNoteOnSend: boolean;
|
||||
attachmentFolder: string;
|
||||
clipFolder: string;
|
||||
clipFilenameTemplate: string;
|
||||
clipTags: string;
|
||||
archiveExportEnabled: boolean;
|
||||
archiveExportFolderTemplate: string;
|
||||
archiveExportFilenameTemplate: string;
|
||||
|
|
@ -26,8 +23,6 @@ export interface CodexPanelSettings {
|
|||
}
|
||||
|
||||
export const DEFAULT_ATTACHMENT_FOLDER = "Codex Attachments";
|
||||
export const DEFAULT_CLIP_FOLDER = "Codex Clippings";
|
||||
export const DEFAULT_CLIP_FILENAME_TEMPLATE = "{{title}}.md";
|
||||
export const DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE = "Codex Archives";
|
||||
export const DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE = "{{date}} {{time}} {{title}} {{shortId}}.md";
|
||||
|
||||
|
|
@ -42,9 +37,6 @@ export const DEFAULT_SETTINGS: CodexPanelSettings = {
|
|||
scrollThreadFromComposerEdges: false,
|
||||
referenceActiveNoteOnSend: false,
|
||||
attachmentFolder: DEFAULT_ATTACHMENT_FOLDER,
|
||||
clipFolder: DEFAULT_CLIP_FOLDER,
|
||||
clipFilenameTemplate: DEFAULT_CLIP_FILENAME_TEMPLATE,
|
||||
clipTags: "",
|
||||
archiveExportEnabled: false,
|
||||
archiveExportFolderTemplate: DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE,
|
||||
archiveExportFilenameTemplate: DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE,
|
||||
|
|
@ -70,11 +62,6 @@ export function normalizeSettings(storedSettings: unknown): CodexPanelSettings {
|
|||
referenceActiveNoteOnSend: booleanOrDefault(record["referenceActiveNoteOnSend"], DEFAULT_SETTINGS.referenceActiveNoteOnSend),
|
||||
attachmentFolder:
|
||||
stringOrDefault(record["attachmentFolder"], DEFAULT_SETTINGS.attachmentFolder).trim() || DEFAULT_SETTINGS.attachmentFolder,
|
||||
clipFolder: stringOrDefault(record["clipFolder"], DEFAULT_SETTINGS.clipFolder).trim() || DEFAULT_SETTINGS.clipFolder,
|
||||
clipFilenameTemplate:
|
||||
stringOrDefault(record["clipFilenameTemplate"], DEFAULT_SETTINGS.clipFilenameTemplate).trim() ||
|
||||
DEFAULT_SETTINGS.clipFilenameTemplate,
|
||||
clipTags: stringOrDefault(record["clipTags"], DEFAULT_SETTINGS.clipTags).trim(),
|
||||
archiveExportEnabled: booleanOrDefault(record["archiveExportEnabled"], DEFAULT_SETTINGS.archiveExportEnabled),
|
||||
archiveExportFolderTemplate:
|
||||
stringOrDefault(record["archiveExportFolderTemplate"], DEFAULT_SETTINGS.archiveExportFolderTemplate).trim() ||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { IconButton, ObsidianCommitTextInput, ObsidianDropdown, ObsidianToggle }
|
|||
import { ArchivedThreadSection } from "./archived-section";
|
||||
import { HelperSettingsSection } from "./helper-section";
|
||||
import { HookSection } from "./hook-section";
|
||||
import { DEFAULT_ATTACHMENT_FOLDER, DEFAULT_CLIP_FILENAME_TEMPLATE, DEFAULT_CLIP_FOLDER } from "./model";
|
||||
import { DEFAULT_ATTACHMENT_FOLDER } from "./model";
|
||||
import type { SettingsSectionsState } from "./section-state";
|
||||
import { SettingRow, SettingsGroup, SettingsHeading, SettingsItems } from "./setting-components";
|
||||
|
||||
|
|
@ -26,9 +26,6 @@ interface SettingsTabPanelState {
|
|||
scrollThreadFromComposerEdges: boolean;
|
||||
referenceActiveNoteOnSend: boolean;
|
||||
attachmentFolder: string;
|
||||
clipFolder: string;
|
||||
clipFilenameTemplate: string;
|
||||
clipTags: string;
|
||||
}
|
||||
|
||||
interface SettingsTabShellActions {
|
||||
|
|
@ -39,9 +36,6 @@ interface SettingsTabShellActions {
|
|||
setScrollThreadFromComposerEdges: (value: boolean) => void;
|
||||
setReferenceActiveNoteOnSend: (value: boolean) => void;
|
||||
setAttachmentFolder: (value: string) => void;
|
||||
setClipFolder: (value: string) => void;
|
||||
setClipFilenameTemplate: (value: string) => void;
|
||||
setClipTags: (value: string) => void;
|
||||
}
|
||||
|
||||
interface SettingsTabShellProps {
|
||||
|
|
@ -109,76 +103,42 @@ function GeneralSettingsSection({ panel, actions }: { panel: SettingsTabPanelSta
|
|||
|
||||
function ComposerSettingsSection({ panel, actions }: { panel: SettingsTabPanelState; actions: SettingsTabShellActions }): UiNode {
|
||||
return (
|
||||
<>
|
||||
<SettingsGroup className="codex-panel-settings__section codex-panel-settings__composer-section">
|
||||
<SettingsHeading name="Composer" />
|
||||
<SettingsItems>
|
||||
<SettingRow
|
||||
name="Send shortcut"
|
||||
desc="Controls whether Enter or Cmd/Ctrl+Enter sends composer-style inputs. Shift+Enter adds a newline."
|
||||
>
|
||||
<ObsidianDropdown
|
||||
value={panel.sendShortcut}
|
||||
onChange={(value) => {
|
||||
actions.setSendShortcut(value === "mod-enter" ? "mod-enter" : "enter");
|
||||
}}
|
||||
options={SEND_SHORTCUT_OPTIONS}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="Scroll conversation from composer line edges"
|
||||
desc="Lets Up/Ctrl+P and Down/Ctrl+N scroll the conversation from composer line edges."
|
||||
>
|
||||
<ObsidianToggle checked={panel.scrollThreadFromComposerEdges} onChange={actions.setScrollThreadFromComposerEdges} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="Reference active file on send"
|
||||
desc="Adds the active file as context on each send without changing the prompt text."
|
||||
>
|
||||
<ObsidianToggle checked={panel.referenceActiveNoteOnSend} onChange={actions.setReferenceActiveNoteOnSend} />
|
||||
</SettingRow>
|
||||
<SettingRow name="Attachment folder" desc="Vault-relative folder for files pasted or dropped into composer inputs.">
|
||||
<ObsidianCommitTextInput
|
||||
value={panel.attachmentFolder}
|
||||
placeholder={DEFAULT_ATTACHMENT_FOLDER}
|
||||
normalizeValue={(value) => value.trim() || DEFAULT_ATTACHMENT_FOLDER}
|
||||
onCommit={actions.setAttachmentFolder}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsItems>
|
||||
</SettingsGroup>
|
||||
<SettingsGroup className="codex-panel-settings__section codex-panel-settings__web-clipping-section">
|
||||
<SettingsHeading name="Web clipping" />
|
||||
<SettingsItems>
|
||||
<SettingRow name="Clipped note folder" desc="Vault-relative folder for notes created by /clip.">
|
||||
<ObsidianCommitTextInput
|
||||
value={panel.clipFolder}
|
||||
placeholder={DEFAULT_CLIP_FOLDER}
|
||||
normalizeValue={(value) => value.trim() || DEFAULT_CLIP_FOLDER}
|
||||
onCommit={actions.setClipFolder}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="Clipped note filename"
|
||||
desc="Filename template. Supports {{date}}, {{time}}, {{title}}, {{site}}, and {{domain}}."
|
||||
>
|
||||
<ObsidianCommitTextInput
|
||||
value={panel.clipFilenameTemplate}
|
||||
placeholder={DEFAULT_CLIP_FILENAME_TEMPLATE}
|
||||
normalizeValue={(value) => value.trim() || DEFAULT_CLIP_FILENAME_TEMPLATE}
|
||||
onCommit={actions.setClipFilenameTemplate}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow name="Clipped note tags" desc="Comma-separated tags added to clipped notes.">
|
||||
<ObsidianCommitTextInput
|
||||
value={panel.clipTags}
|
||||
placeholder="web, clipping"
|
||||
normalizeValue={(value) => value.trim()}
|
||||
onCommit={actions.setClipTags}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsItems>
|
||||
</SettingsGroup>
|
||||
</>
|
||||
<SettingsGroup className="codex-panel-settings__section codex-panel-settings__composer-section">
|
||||
<SettingsHeading name="Composer" />
|
||||
<SettingsItems>
|
||||
<SettingRow
|
||||
name="Send shortcut"
|
||||
desc="Controls whether Enter or Cmd/Ctrl+Enter sends composer-style inputs. Shift+Enter adds a newline."
|
||||
>
|
||||
<ObsidianDropdown
|
||||
value={panel.sendShortcut}
|
||||
onChange={(value) => {
|
||||
actions.setSendShortcut(value === "mod-enter" ? "mod-enter" : "enter");
|
||||
}}
|
||||
options={SEND_SHORTCUT_OPTIONS}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="Scroll conversation from composer line edges"
|
||||
desc="Lets Up/Ctrl+P and Down/Ctrl+N scroll the conversation from composer line edges."
|
||||
>
|
||||
<ObsidianToggle checked={panel.scrollThreadFromComposerEdges} onChange={actions.setScrollThreadFromComposerEdges} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="Reference active file on send"
|
||||
desc="Adds the active file as context on each send without changing the prompt text."
|
||||
>
|
||||
<ObsidianToggle checked={panel.referenceActiveNoteOnSend} onChange={actions.setReferenceActiveNoteOnSend} />
|
||||
</SettingRow>
|
||||
<SettingRow name="Attachment folder" desc="Vault-relative folder for files pasted or dropped into composer inputs.">
|
||||
<ObsidianCommitTextInput
|
||||
value={panel.attachmentFolder}
|
||||
placeholder={DEFAULT_ATTACHMENT_FOLDER}
|
||||
normalizeValue={(value) => value.trim() || DEFAULT_ATTACHMENT_FOLDER}
|
||||
onCommit={actions.setAttachmentFolder}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsItems>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,7 @@ import { renderUiRoot, unmountUiRoot } from "../shared/dom/preact-root.dom";
|
|||
import { SettingsDynamicSectionsController } from "./dynamic-sections-controller";
|
||||
import type { CodexPanelSettingTabHost } from "./host";
|
||||
import type { CodexPanelSettings } from "./model";
|
||||
import {
|
||||
DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE,
|
||||
DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE,
|
||||
DEFAULT_ATTACHMENT_FOLDER,
|
||||
DEFAULT_CLIP_FILENAME_TEMPLATE,
|
||||
DEFAULT_CLIP_FOLDER,
|
||||
} from "./model";
|
||||
import { DEFAULT_ARCHIVE_EXPORT_FILENAME_TEMPLATE, DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE, DEFAULT_ATTACHMENT_FOLDER } from "./model";
|
||||
import type { SettingsSectionsState } from "./section-state";
|
||||
import { SettingsTabShell } from "./tab-shell";
|
||||
|
||||
|
|
@ -95,9 +89,6 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
scrollThreadFromComposerEdges: this.plugin.settings.scrollThreadFromComposerEdges,
|
||||
referenceActiveNoteOnSend: this.plugin.settings.referenceActiveNoteOnSend,
|
||||
attachmentFolder: this.plugin.settings.attachmentFolder,
|
||||
clipFolder: this.plugin.settings.clipFolder,
|
||||
clipFilenameTemplate: this.plugin.settings.clipFilenameTemplate,
|
||||
clipTags: this.plugin.settings.clipTags,
|
||||
}}
|
||||
sections={this.settingsSectionsState()}
|
||||
actions={{
|
||||
|
|
@ -122,15 +113,6 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
setAttachmentFolder: (value) => {
|
||||
void this.setAttachmentFolder(value);
|
||||
},
|
||||
setClipFolder: (value) => {
|
||||
void this.setClipFolder(value);
|
||||
},
|
||||
setClipFilenameTemplate: (value) => {
|
||||
void this.setClipFilenameTemplate(value);
|
||||
},
|
||||
setClipTags: (value) => {
|
||||
void this.setClipTags(value);
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
|
@ -255,24 +237,6 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
});
|
||||
}
|
||||
|
||||
private setClipFolder(value: string): Promise<void> {
|
||||
return this.queueSettingsMutation(() => {
|
||||
this.plugin.settings.clipFolder = value.trim() || DEFAULT_CLIP_FOLDER;
|
||||
});
|
||||
}
|
||||
|
||||
private setClipFilenameTemplate(value: string): Promise<void> {
|
||||
return this.queueSettingsMutation(() => {
|
||||
this.plugin.settings.clipFilenameTemplate = value.trim() || DEFAULT_CLIP_FILENAME_TEMPLATE;
|
||||
});
|
||||
}
|
||||
|
||||
private setClipTags(value: string): Promise<void> {
|
||||
return this.queueSettingsMutation(() => {
|
||||
this.plugin.settings.clipTags = value.trim();
|
||||
});
|
||||
}
|
||||
|
||||
private setArchiveExportEnabled(enabled: boolean): Promise<void> {
|
||||
return this.queueSettingsMutation(() => {
|
||||
this.plugin.settings.archiveExportEnabled = enabled;
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ describe("composer suggestions", () => {
|
|||
{ input: "/clear", expected: { command: "clear", args: "" } },
|
||||
{ input: "/resume thread-1", expected: { command: "resume", args: "thread-1" } },
|
||||
{ input: "/refer thread-1 続きです", expected: { command: "refer", args: "thread-1 続きです" } },
|
||||
{ input: "/clip https://example.com/article 要約して", expected: { command: "clip", args: "https://example.com/article 要約して" } },
|
||||
{ input: "/web https://example.com/article 要約して", expected: { command: "web", args: "https://example.com/article 要約して" } },
|
||||
{ input: "/fork", expected: { command: "fork", args: "" } },
|
||||
{ input: "/archive thread-1", expected: { command: "archive", args: "thread-1" } },
|
||||
{ input: "/rename thread-1 New name", expected: { command: "rename", args: "thread-1 New name" } },
|
||||
|
|
|
|||
|
|
@ -148,12 +148,12 @@ describe("submitComposer", () => {
|
|||
});
|
||||
|
||||
it("restores slash command text when command send results are not submitted", async () => {
|
||||
const { host, execute, inputSnapshot, sendTurnText, setDraft } = createHost("/clip https://example.com [[Note]]");
|
||||
const { host, execute, inputSnapshot, sendTurnText, setDraft } = createHost("/web https://example.com [[Note]]");
|
||||
execute.mockResolvedValue({
|
||||
sendText: "[[Codex Clippings/Example.md]] [[Note]]",
|
||||
sendText: "https://example.com/ [[Note]]",
|
||||
sendInput: [
|
||||
{ type: "text", text: "[[Codex Clippings/Example.md]] [[Note]]" },
|
||||
{ type: "mention", name: "Example", path: "Codex Clippings/Example.md" },
|
||||
{ type: "text", text: "https://example.com/ [[Note]]" },
|
||||
{ type: "additionalContext", key: "codex_panel_web_context", kind: "untrusted", value: "Readable article" },
|
||||
{ type: "mention", name: "Note", path: "Note.md" },
|
||||
],
|
||||
});
|
||||
|
|
@ -162,16 +162,16 @@ describe("submitComposer", () => {
|
|||
await submitComposer(host);
|
||||
|
||||
expect(sendTurnText).toHaveBeenCalledWith({
|
||||
text: "[[Codex Clippings/Example.md]] [[Note]]",
|
||||
text: "https://example.com/ [[Note]]",
|
||||
inputSnapshot,
|
||||
codexInputOverride: [
|
||||
{ type: "text", text: "[[Codex Clippings/Example.md]] [[Note]]" },
|
||||
{ type: "mention", name: "Example", path: "Codex Clippings/Example.md" },
|
||||
{ type: "text", text: "https://example.com/ [[Note]]" },
|
||||
{ type: "additionalContext", key: "codex_panel_web_context", kind: "untrusted", value: "Readable article" },
|
||||
{ type: "mention", name: "Note", path: "Note.md" },
|
||||
],
|
||||
preserveComposerContextOnFailure: true,
|
||||
});
|
||||
expect(setDraft).toHaveBeenCalledWith("/clip https://example.com [[Note]]", { focus: true, clearSuggestions: true });
|
||||
expect(setDraft).toHaveBeenCalledWith("/web https://example.com [[Note]]", { focus: true, clearSuggestions: true });
|
||||
});
|
||||
|
||||
it("does not execute connection-dependent slash commands when connection fails", async () => {
|
||||
|
|
@ -219,17 +219,17 @@ describe("submitComposer", () => {
|
|||
});
|
||||
|
||||
it("restores slash command text and reports executor errors", async () => {
|
||||
const { host, execute, sendTurnText, setDraft, showLatest } = createHost("/clip https://obsidian.md/help/plugins/web-viewer 読める?");
|
||||
const { host, execute, sendTurnText, setDraft, showLatest } = createHost("/web https://obsidian.md/help/plugins/web-viewer 読める?");
|
||||
execute.mockRejectedValue(new Error("No readable content found for https://obsidian.md/help/plugins/web-viewer"));
|
||||
|
||||
await submitComposer(host);
|
||||
|
||||
expect(setDraft).toHaveBeenCalledWith("/clip https://obsidian.md/help/plugins/web-viewer 読める?", {
|
||||
expect(setDraft).toHaveBeenCalledWith("/web https://obsidian.md/help/plugins/web-viewer 読める?", {
|
||||
focus: true,
|
||||
clearSuggestions: true,
|
||||
});
|
||||
expect(setDraft.mock.calls.at(-1)).toEqual([
|
||||
"/clip https://obsidian.md/help/plugins/web-viewer 読める?",
|
||||
"/web https://obsidian.md/help/plugins/web-viewer 読める?",
|
||||
{ focus: true, clearSuggestions: true },
|
||||
]);
|
||||
expect(host.status.addSystemMessage).toHaveBeenCalledWith("No readable content found for https://obsidian.md/help/plugins/web-viewer");
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ describe("createTurnWorkflowActions", () => {
|
|||
interruptTurn: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
referThread: vi.fn(),
|
||||
clipUrl: vi.fn(),
|
||||
readWebUrl: vi.fn(),
|
||||
status: {
|
||||
set: vi.fn(),
|
||||
addSystemMessage: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
|
|||
input: [{ type: "text", text: "referenced" }],
|
||||
referencedThread: { threadId: "thread-2", title: "Referenced", includedTurns: 1, turnLimit: 20 },
|
||||
}),
|
||||
clipUrl: vi.fn().mockResolvedValue({
|
||||
text: "[[Codex Clippings/Example.md]] 要約して",
|
||||
readWebUrl: vi.fn().mockResolvedValue({
|
||||
text: "https://example.com/article 要約して",
|
||||
input: [
|
||||
{ type: "text", text: "[[Codex Clippings/Example.md]] 要約して" },
|
||||
{ type: "mention", name: "Example", path: "Codex Clippings/Example.md" },
|
||||
{ type: "text", text: "https://example.com/article 要約して" },
|
||||
{ type: "additionalContext", key: "codex_panel_web_context", kind: "untrusted", value: "Readable article" },
|
||||
],
|
||||
}),
|
||||
threadActions: {
|
||||
|
|
@ -231,56 +231,56 @@ describe("slash commands", () => {
|
|||
expect(result).toEqual({ sendText: "質問です", sendInput: input, referencedThread });
|
||||
});
|
||||
|
||||
it("returns clipped wikilink input for /clip", async () => {
|
||||
it("returns fetched web context input for /web", async () => {
|
||||
const inputSnapshot = { sourcePath: "snapshot.md" } as never;
|
||||
const input = [
|
||||
{ type: "text" as const, text: "[[Codex Clippings/Example.md]] 要約して" },
|
||||
{ type: "mention" as const, name: "Example", path: "Codex Clippings/Example.md" },
|
||||
{ type: "text" as const, text: "https://example.com/article 要約して" },
|
||||
{ type: "additionalContext" as const, key: "codex_panel_web_context", kind: "untrusted" as const, value: "Readable article" },
|
||||
];
|
||||
const ctx = context({
|
||||
inputSnapshot,
|
||||
clipUrl: vi.fn().mockResolvedValue({ text: "[[Codex Clippings/Example.md]] 要約して", input }),
|
||||
readWebUrl: vi.fn().mockResolvedValue({ text: "https://example.com/article 要約して", input }),
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand("clip", "https://example.com/article 要約して", ctx);
|
||||
const result = await executeSlashCommand("web", "https://example.com/article 要約して", ctx);
|
||||
|
||||
expect(ctx.clipUrl).toHaveBeenCalledWith("https://example.com/article", "要約して", inputSnapshot);
|
||||
expect(result).toEqual({ sendText: "[[Codex Clippings/Example.md]] 要約して", sendInput: input });
|
||||
expect(ctx.readWebUrl).toHaveBeenCalledWith("https://example.com/article", "要約して", inputSnapshot);
|
||||
expect(result).toEqual({ sendText: "https://example.com/article 要約して", sendInput: input });
|
||||
});
|
||||
|
||||
it("returns clipped wikilink input for /clip without a message", async () => {
|
||||
it("returns fetched web context input for /web without a message", async () => {
|
||||
const inputSnapshot = { sourcePath: "snapshot.md" } as never;
|
||||
const input = [
|
||||
{ type: "text" as const, text: "[[Codex Clippings/Example.md]]" },
|
||||
{ type: "mention" as const, name: "Example", path: "Codex Clippings/Example.md" },
|
||||
{ type: "text" as const, text: "https://example.com/article" },
|
||||
{ type: "additionalContext" as const, key: "codex_panel_web_context", kind: "untrusted" as const, value: "Readable article" },
|
||||
];
|
||||
const ctx = context({
|
||||
inputSnapshot,
|
||||
clipUrl: vi.fn().mockResolvedValue({ text: "[[Codex Clippings/Example.md]]", input }),
|
||||
readWebUrl: vi.fn().mockResolvedValue({ text: "https://example.com/article", input }),
|
||||
});
|
||||
|
||||
const result = await executeSlashCommand("clip", "https://example.com/article", ctx);
|
||||
const result = await executeSlashCommand("web", "https://example.com/article", ctx);
|
||||
|
||||
expect(ctx.clipUrl).toHaveBeenCalledWith("https://example.com/article", "", inputSnapshot);
|
||||
expect(result).toEqual({ sendText: "[[Codex Clippings/Example.md]]", sendInput: input });
|
||||
expect(ctx.readWebUrl).toHaveBeenCalledWith("https://example.com/article", "", inputSnapshot);
|
||||
expect(result).toEqual({ sendText: "https://example.com/article", sendInput: input });
|
||||
});
|
||||
|
||||
it("rejects /clip without a URL", async () => {
|
||||
it("rejects /web without a URL", async () => {
|
||||
const ctx = context();
|
||||
|
||||
await executeSlashCommand("clip", "", ctx);
|
||||
await executeSlashCommand("web", "", ctx);
|
||||
|
||||
expect(ctx.clipUrl).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/clip requires a URL. Usage: /clip <url> [message]");
|
||||
expect(ctx.readWebUrl).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/web requires a URL. Usage: /web <url> [message]");
|
||||
});
|
||||
|
||||
it("rejects /clip when no composer input snapshot is available", async () => {
|
||||
it("rejects /web when no composer input snapshot is available", async () => {
|
||||
const ctx = context();
|
||||
|
||||
await executeSlashCommand("clip", "https://example.com/article 要約して", ctx);
|
||||
await executeSlashCommand("web", "https://example.com/article 要約して", ctx);
|
||||
|
||||
expect(ctx.clipUrl).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Cannot clip a URL without composer input context.");
|
||||
expect(ctx.readWebUrl).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Cannot read a web URL without composer input context.");
|
||||
});
|
||||
|
||||
it("forks the active thread for /fork", async () => {
|
||||
|
|
@ -647,7 +647,7 @@ describe("slash commands", () => {
|
|||
expect.arrayContaining(["/plan [message]", "/goal", "/permissions [profile|default]", "/model [model|default]"]),
|
||||
);
|
||||
expect(slashCommandHelpKeys("Diagnostics")).toEqual(expect.arrayContaining(["/status", "/doctor", "/tools", "/help"]));
|
||||
expect(slashCommandHelpKeys("Composition")).toEqual(["/refer <thread> <message>", "/clip <url> [message]"]);
|
||||
expect(slashCommandHelpKeys("Composition")).toEqual(["/refer <thread> <message>", "/web <url> [message]"]);
|
||||
});
|
||||
|
||||
it("rejects /help arguments", async () => {
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) {
|
|||
const stateStore = createChatStateStore(createChatState());
|
||||
const compactThread = vi.fn().mockResolvedValue(undefined);
|
||||
const referThread = vi.fn().mockResolvedValue(null);
|
||||
const clipUrl = vi.fn().mockResolvedValue(null);
|
||||
const readWebUrl = vi.fn();
|
||||
const host: SlashCommandExecutorHost = {
|
||||
stateStore,
|
||||
connectionAvailable: () => true,
|
||||
referThread,
|
||||
clipUrl,
|
||||
readWebUrl,
|
||||
startNewThread: vi.fn().mockResolvedValue(undefined),
|
||||
startThreadForGoal: vi.fn().mockResolvedValue("thread-new"),
|
||||
resumeThread: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -74,7 +74,7 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) {
|
|||
effortStatusDetails: () => [],
|
||||
...overrides,
|
||||
};
|
||||
return { clipUrl, compactThread, host, referThread, stateStore };
|
||||
return { compactThread, host, readWebUrl, referThread, stateStore };
|
||||
}
|
||||
|
||||
describe("executeSlashCommandWithState", () => {
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
saveWebClipMarkdown,
|
||||
type WebClipDestination,
|
||||
webClipMarkdown,
|
||||
} from "../../../../../src/features/chat/application/web-clipping/web-clip";
|
||||
|
||||
describe("web clipping", () => {
|
||||
it("writes clipped markdown with fixed frontmatter and tags", () => {
|
||||
const output = webClipMarkdown(
|
||||
{
|
||||
url: "https://example.com/post",
|
||||
title: "Example Post",
|
||||
content: "Body",
|
||||
},
|
||||
{ clipTags: '#web, "clipping", web, {{title}}' },
|
||||
"Example Post",
|
||||
new Date("2026-07-05T03:04:05.000Z"),
|
||||
);
|
||||
|
||||
expect(output).toBe(
|
||||
[
|
||||
"---",
|
||||
'title: "Example Post"',
|
||||
'url: "https://example.com/post"',
|
||||
'created: "2026-07-05T03:04:05.000Z"',
|
||||
'tags: ["web", "clipping", "{{title}}"]',
|
||||
"---",
|
||||
"",
|
||||
"# Example Post",
|
||||
"",
|
||||
"Body",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes frontmatter strings with YAML-safe JSON string literals", () => {
|
||||
const output = webClipMarkdown(
|
||||
{
|
||||
url: "https://example.com/post?title=line\nbreak",
|
||||
title: "Example Post",
|
||||
content: "Body",
|
||||
},
|
||||
{ clipTags: "web\nclip, tab\tvalue" },
|
||||
"Example\nPost",
|
||||
new Date("2026-07-05T03:04:05.000Z"),
|
||||
);
|
||||
|
||||
expect(output).toContain('title: "Example Post"');
|
||||
expect(output).toContain('url: "https://example.com/post?title=line\\nbreak"');
|
||||
expect(output).toContain('tags: ["web\\nclip", "tab\\tvalue"]');
|
||||
});
|
||||
|
||||
it("saves clips under a unique path from the filename template", async () => {
|
||||
const destination = memoryDestination(["Codex Clippings/Example Site - Example-Post.md"]);
|
||||
|
||||
const result = await saveWebClipMarkdown(
|
||||
{
|
||||
url: "https://example.com/post",
|
||||
title: "Example/Post",
|
||||
site: "Example Site",
|
||||
domain: "example.com",
|
||||
content: "Body",
|
||||
},
|
||||
{
|
||||
clipFolder: "Codex Clippings",
|
||||
clipFilenameTemplate: "{{site}} - {{title}}",
|
||||
clipTags: "",
|
||||
},
|
||||
destination,
|
||||
new Date("2026-07-05T03:04:05.000Z"),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
path: "Codex Clippings/Example Site - Example-Post 2.md",
|
||||
wikilink: "[[Codex Clippings/Example Site - Example-Post 2.md]]",
|
||||
});
|
||||
expect(destination.createFolder).toHaveBeenCalledWith("Codex Clippings");
|
||||
expect(destination.createMarkdownFile).toHaveBeenCalledWith("Codex Clippings/Example Site - Example-Post 2.md", expect.any(String));
|
||||
});
|
||||
|
||||
it("rejects absolute and relative clip folders", async () => {
|
||||
const destination = memoryDestination([]);
|
||||
|
||||
await expect(
|
||||
saveWebClipMarkdown(
|
||||
{ url: "https://example.com", title: "Example", content: "Body" },
|
||||
{ clipFolder: "../outside", clipFilenameTemplate: "{{title}}.md" },
|
||||
destination,
|
||||
),
|
||||
).rejects.toThrow("Clip folder cannot contain relative path segments.");
|
||||
});
|
||||
});
|
||||
|
||||
function memoryDestination(existingPaths: readonly string[]): WebClipDestination & {
|
||||
createFolder: ReturnType<typeof vi.fn<WebClipDestination["createFolder"]>>;
|
||||
createMarkdownFile: ReturnType<typeof vi.fn<WebClipDestination["createMarkdownFile"]>>;
|
||||
} {
|
||||
const paths = new Set(existingPaths);
|
||||
return {
|
||||
normalizePath: (path) => path.replace(/[\\/]+/g, "/").replace(/^\/+|\/+$/g, ""),
|
||||
exists: vi.fn(async (path) => paths.has(path)),
|
||||
createFolder: vi.fn<WebClipDestination["createFolder"]>().mockResolvedValue(undefined),
|
||||
createMarkdownFile: vi.fn<WebClipDestination["createMarkdownFile"]>().mockImplementation(async (path) => {
|
||||
paths.add(path);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import type { Vault } from "obsidian";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ComposerInputSnapshot } from "../../../../src/features/chat/application/composer/input-snapshot";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("obsidian", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("obsidian")>();
|
||||
return {
|
||||
...actual,
|
||||
requestUrl: mocks.requestUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const { TFile } = await import("obsidian");
|
||||
const { createVaultWebClipper } = await import("../../../../src/features/chat/host/obsidian/web-clipper.obsidian");
|
||||
|
||||
describe("vault web clipper parser integration", () => {
|
||||
it("extracts article HTML with Defuddle and saves it as Markdown", async () => {
|
||||
mocks.requestUrl.mockResolvedValue({
|
||||
text: `<!doctype html>
|
||||
<html>
|
||||
<head><title>Integration Article</title></head>
|
||||
<body>
|
||||
<nav>Navigation that should not be clipped</nav>
|
||||
<main>
|
||||
<article>
|
||||
<h1>Parser contract heading</h1>
|
||||
<p>This readable paragraph exercises the real Defuddle full browser bundle.</p>
|
||||
<p>A second paragraph makes the article content unambiguous.</p>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>`,
|
||||
});
|
||||
const { vault, createdContent } = memoryVault();
|
||||
|
||||
const result = await createVaultWebClipper({
|
||||
vault,
|
||||
settings: () => ({ clipFolder: "Clips", clipFilenameTemplate: "{{title}}.md", clipTags: "" }),
|
||||
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
|
||||
viewWindow: () => window,
|
||||
now: () => new Date("2026-07-10T00:00:00.000Z"),
|
||||
}).clipUrl("https://example.com/article", "", {} as ComposerInputSnapshot);
|
||||
|
||||
const markdown = createdContent.get("Clips/Integration Article.md");
|
||||
expect(markdown).toContain("## Parser contract heading");
|
||||
expect(markdown).toContain("This readable paragraph exercises the real Defuddle full browser bundle.");
|
||||
expect(markdown).not.toContain("<article");
|
||||
expect(markdown).not.toContain("Navigation that should not be clipped");
|
||||
expect(result?.text).toBe("[[Clips/Integration Article.md]]");
|
||||
});
|
||||
|
||||
it("preserves full-parser Markdown features required by the clip contract", async () => {
|
||||
mocks.requestUrl.mockResolvedValue({
|
||||
text: `<!doctype html><html><head><title>Rich Article</title></head><body><article>
|
||||
<h1>Rich heading</h1><p>Formula <math><mi>x</mi></math></p>
|
||||
<p>See the note<sup><a href="#fn-1">1</a></sup>.</p>
|
||||
<blockquote class="callout"><p>Obsidian callout</p></blockquote>
|
||||
<pre><code>const value = 1;</code></pre>
|
||||
<ol><li id="fn-1">Footnote detail</li></ol>
|
||||
</article></body></html>`,
|
||||
});
|
||||
const { vault, createdContent } = memoryVault();
|
||||
|
||||
await createVaultWebClipper({
|
||||
vault,
|
||||
settings: () => ({ clipFolder: "Clips", clipFilenameTemplate: "{{title}}.md", clipTags: "" }),
|
||||
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
|
||||
viewWindow: () => window,
|
||||
now: () => new Date("2026-07-10T00:00:00.000Z"),
|
||||
}).clipUrl("https://example.com/rich", "", {} as ComposerInputSnapshot);
|
||||
|
||||
const markdown = createdContent.get("Clips/Rich Article.md") ?? "";
|
||||
expect(markdown).toContain("Rich heading");
|
||||
expect(markdown).toMatch(/\$|\\frac|math/);
|
||||
expect(markdown).toContain("Footnote detail");
|
||||
expect(markdown).toContain("```\nconst value = 1;\n```");
|
||||
});
|
||||
});
|
||||
|
||||
function memoryVault(): { vault: Vault; createdContent: Map<string, string> } {
|
||||
const files = new Map<string, InstanceType<typeof TFile>>();
|
||||
const createdContent = new Map<string, string>();
|
||||
const vault = {
|
||||
getAbstractFileByPath: vi.fn((path: string) => files.get(path) ?? null),
|
||||
createFolder: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockImplementation(async (path: string, content: string) => {
|
||||
const File = TFile as unknown as new (path: string) => InstanceType<typeof TFile>;
|
||||
files.set(path, new File(path));
|
||||
createdContent.set(path, content);
|
||||
}),
|
||||
};
|
||||
return { vault: vault as unknown as Vault, createdContent };
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import type { Vault } from "obsidian";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CodexInput } from "../../../../src/domain/chat/input";
|
||||
import type { ComposerInputSnapshot } from "../../../../src/features/chat/application/composer/input-snapshot";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
defuddleParse: vi.fn(),
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("obsidian", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("obsidian")>();
|
||||
return {
|
||||
...actual,
|
||||
requestUrl: mocks.requestUrl,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("defuddle/full", () => ({
|
||||
default: vi.fn().mockImplementation(function MockDefuddle() {
|
||||
return { parse: mocks.defuddleParse };
|
||||
}),
|
||||
}));
|
||||
|
||||
const { TFile } = await import("obsidian");
|
||||
const { createVaultWebClipper } = await import("../../../../src/features/chat/host/obsidian/web-clipper.obsidian");
|
||||
|
||||
describe("vault web clipper", () => {
|
||||
beforeEach(() => {
|
||||
mocks.requestUrl.mockResolvedValue({ text: "<html><body>Article</body></html>" });
|
||||
mocks.defuddleParse.mockReturnValue({
|
||||
title: "Example",
|
||||
contentMarkdown: "Readable article",
|
||||
content: "Readable article",
|
||||
site: "Example Site",
|
||||
domain: "example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves composer-prepared message context alongside the clipped note mention", async () => {
|
||||
const vault = memoryVault();
|
||||
const inputSnapshot = { sourcePath: "source.md" } as ComposerInputSnapshot;
|
||||
const messageInput = [
|
||||
{ type: "text" as const, text: "Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]" },
|
||||
{ type: "mention" as const, name: "Alpha", path: "Notes/Alpha.md" },
|
||||
{ type: "additionalContext" as const, key: "codex_panel_obsidian_context", kind: "untrusted" as const, value: "selection" },
|
||||
{ type: "mention" as const, name: "Sketch.png", path: "Files/Sketch.png" },
|
||||
{ type: "localImage" as const, path: "Files/Sketch.png" },
|
||||
] satisfies CodexInput;
|
||||
const prepareInput = vi.fn(() => ({
|
||||
text: "Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]",
|
||||
input: messageInput,
|
||||
}));
|
||||
|
||||
const result = await createVaultWebClipper({
|
||||
vault,
|
||||
settings: () => ({ clipFolder: "Codex Clippings", clipFilenameTemplate: "{{title}}.md", clipTags: "" }),
|
||||
prepareInput,
|
||||
viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window,
|
||||
now: () => new Date("2026-07-05T00:00:00.000Z"),
|
||||
}).clipUrl("https://example.com/article", " Summarize [[Alpha]] [[Files/Sketch.png]] ", inputSnapshot);
|
||||
|
||||
expect(prepareInput).toHaveBeenCalledWith("Summarize [[Alpha]] [[Files/Sketch.png]]", inputSnapshot);
|
||||
expect(result).toEqual({
|
||||
text: "[[Codex Clippings/Example.md]] Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]",
|
||||
input: [
|
||||
{ type: "text", text: "[[Codex Clippings/Example.md]] Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]" },
|
||||
{ type: "mention", name: "Example", path: "Codex Clippings/Example.md" },
|
||||
{ type: "mention", name: "Alpha", path: "Notes/Alpha.md" },
|
||||
{ type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selection" },
|
||||
{ type: "mention", name: "Sketch.png", path: "Files/Sketch.png" },
|
||||
{ type: "localImage", path: "Files/Sketch.png" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class FakeDOMParser {
|
||||
parseFromString(): Document {
|
||||
return {} as Document;
|
||||
}
|
||||
}
|
||||
|
||||
function memoryVault(): Vault {
|
||||
const files = new Map<string, InstanceType<typeof TFile>>();
|
||||
const vault = {
|
||||
getAbstractFileByPath: vi.fn((path: string) => files.get(path) ?? null),
|
||||
createFolder: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockImplementation(async (path: string) => {
|
||||
const File = TFile as unknown as new (path: string) => InstanceType<typeof TFile>;
|
||||
files.set(path, new File(path));
|
||||
}),
|
||||
};
|
||||
return vault as unknown as Vault;
|
||||
}
|
||||
61
tests/features/chat/host/web-context.integration.test.ts
Normal file
61
tests/features/chat/host/web-context.integration.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ComposerInputSnapshot } from "../../../../src/features/chat/application/composer/input-snapshot";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
htmlToMarkdown: vi.fn(),
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("obsidian", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("obsidian")>();
|
||||
return {
|
||||
...actual,
|
||||
htmlToMarkdown: mocks.htmlToMarkdown,
|
||||
requestUrl: mocks.requestUrl,
|
||||
};
|
||||
});
|
||||
|
||||
const { createWebContextReader } = await import("../../../../src/features/chat/host/obsidian/web-context.obsidian");
|
||||
|
||||
describe("web context parser integration", () => {
|
||||
it("extracts article HTML with the Defuddle core bundle before Markdown conversion", async () => {
|
||||
mocks.requestUrl.mockResolvedValue({
|
||||
status: 200,
|
||||
text: `<!doctype html>
|
||||
<html>
|
||||
<head><title>Integration Article</title></head>
|
||||
<body>
|
||||
<nav>Navigation that should not be included</nav>
|
||||
<main>
|
||||
<article>
|
||||
<h1>Parser contract heading</h1>
|
||||
<p>This readable paragraph exercises the real Defuddle core browser bundle.</p>
|
||||
<p>A second paragraph makes the article content unambiguous.</p>
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>`,
|
||||
});
|
||||
mocks.htmlToMarkdown.mockReturnValue("## Parser contract heading\n\nReadable article");
|
||||
|
||||
const result = await createWebContextReader({
|
||||
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
|
||||
viewWindow: () => window,
|
||||
}).readUrl("https://example.com/article", "", {} as ComposerInputSnapshot);
|
||||
|
||||
const extractedHtml = mocks.htmlToMarkdown.mock.calls[0]?.[0] as string;
|
||||
expect(extractedHtml).toContain("This readable paragraph exercises the real Defuddle core browser bundle.");
|
||||
expect(extractedHtml).not.toContain("Navigation that should not be included");
|
||||
expect(result.text).toBe("https://example.com/article");
|
||||
expect(result.input).toContainEqual({
|
||||
type: "additionalContext",
|
||||
key: "codex_panel_web_context",
|
||||
kind: "untrusted",
|
||||
value:
|
||||
"Web page context for the current user input:\nSource: https://example.com/article\nTitle: Integration Article\n\n## Parser contract heading\n\nReadable article",
|
||||
});
|
||||
});
|
||||
});
|
||||
119
tests/features/chat/host/web-context.test.ts
Normal file
119
tests/features/chat/host/web-context.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { CodexInput } from "../../../../src/domain/chat/input";
|
||||
import type { ComposerInputSnapshot } from "../../../../src/features/chat/application/composer/input-snapshot";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
defuddleParse: vi.fn(),
|
||||
htmlToMarkdown: vi.fn(),
|
||||
requestUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("obsidian", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("obsidian")>();
|
||||
return {
|
||||
...actual,
|
||||
htmlToMarkdown: mocks.htmlToMarkdown,
|
||||
requestUrl: mocks.requestUrl,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("defuddle", () => ({
|
||||
default: vi.fn().mockImplementation(function MockDefuddle() {
|
||||
return { parse: mocks.defuddleParse };
|
||||
}),
|
||||
}));
|
||||
|
||||
const { createWebContextReader } = await import("../../../../src/features/chat/host/obsidian/web-context.obsidian");
|
||||
|
||||
describe("web context reader", () => {
|
||||
beforeEach(() => {
|
||||
mocks.requestUrl.mockReset();
|
||||
mocks.defuddleParse.mockReset();
|
||||
mocks.htmlToMarkdown.mockReset();
|
||||
mocks.requestUrl.mockResolvedValue({ status: 200, text: "<html><body>Article</body></html>" });
|
||||
mocks.defuddleParse.mockReturnValue({ title: "Example", content: "<article>Readable article</article>" });
|
||||
mocks.htmlToMarkdown.mockReturnValue("Readable article");
|
||||
});
|
||||
|
||||
it("attaches fetched Markdown as untrusted context while preserving prepared message input", async () => {
|
||||
const inputSnapshot = { sourcePath: "source.md" } as ComposerInputSnapshot;
|
||||
const messageInput = [
|
||||
{ type: "text" as const, text: "Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]" },
|
||||
{ type: "mention" as const, name: "Alpha", path: "Notes/Alpha.md" },
|
||||
{ type: "additionalContext" as const, key: "codex_panel_obsidian_context", kind: "untrusted" as const, value: "selection" },
|
||||
{ type: "mention" as const, name: "Sketch.png", path: "Files/Sketch.png" },
|
||||
{ type: "localImage" as const, path: "Files/Sketch.png" },
|
||||
] satisfies CodexInput;
|
||||
const prepareInput = vi.fn(() => ({
|
||||
text: "Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]",
|
||||
input: messageInput,
|
||||
}));
|
||||
|
||||
const result = await createWebContextReader({
|
||||
prepareInput,
|
||||
viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window,
|
||||
}).readUrl("https://example.com/article", " Summarize [[Alpha]] [[Files/Sketch.png]] ", inputSnapshot);
|
||||
|
||||
expect(mocks.requestUrl).toHaveBeenCalledWith({ url: "https://example.com/article", method: "GET", throw: false });
|
||||
expect(mocks.htmlToMarkdown).toHaveBeenCalledWith("<article>Readable article</article>");
|
||||
expect(prepareInput).toHaveBeenCalledWith("Summarize [[Alpha]] [[Files/Sketch.png]]", inputSnapshot);
|
||||
expect(result).toEqual({
|
||||
text: "https://example.com/article Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]",
|
||||
input: [
|
||||
{ type: "text", text: "https://example.com/article Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]" },
|
||||
{ type: "mention", name: "Alpha", path: "Notes/Alpha.md" },
|
||||
{ type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selection" },
|
||||
{ type: "mention", name: "Sketch.png", path: "Files/Sketch.png" },
|
||||
{ type: "localImage", path: "Files/Sketch.png" },
|
||||
{
|
||||
type: "additionalContext",
|
||||
key: "codex_panel_web_context",
|
||||
kind: "untrusted",
|
||||
value: "Web page context for the current user input:\nSource: https://example.com/article\nTitle: Example\n\nReadable article",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([400, 500])("rejects HTTP %i responses", async (status) => {
|
||||
mocks.requestUrl.mockResolvedValue({ status, text: "Error" });
|
||||
|
||||
await expect(
|
||||
createWebContextReader({
|
||||
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
|
||||
viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window,
|
||||
}).readUrl("https://example.com/article", "", {} as ComposerInputSnapshot),
|
||||
).rejects.toThrow(`Web request failed for https://example.com/article (HTTP ${status}).`);
|
||||
|
||||
expect(mocks.defuddleParse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects empty converted content", async () => {
|
||||
mocks.htmlToMarkdown.mockReturnValue(" \n");
|
||||
|
||||
await expect(
|
||||
createWebContextReader({
|
||||
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
|
||||
viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window,
|
||||
}).readUrl("https://example.com/article", "", {} as ComposerInputSnapshot),
|
||||
).rejects.toThrow("No readable web content found for https://example.com/article");
|
||||
});
|
||||
|
||||
it("rejects non-HTTP URLs before fetching", async () => {
|
||||
await expect(
|
||||
createWebContextReader({
|
||||
prepareInput: () => ({ text: "", input: [{ type: "text", text: "" }] }),
|
||||
viewWindow: () => ({ DOMParser: FakeDOMParser }) as unknown as Window,
|
||||
}).readUrl("file:///tmp/article.html", "", {} as ComposerInputSnapshot),
|
||||
).rejects.toThrow("Unsupported web URL: file:///tmp/article.html");
|
||||
|
||||
expect(mocks.requestUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
class FakeDOMParser {
|
||||
parseFromString(): Document {
|
||||
return {} as Document;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,6 @@ export function chatPanelSettingsAccess(settings: CodexPanelSettings): ChatPanel
|
|||
return {
|
||||
referenceActiveNoteOnSend: () => settings.referenceActiveNoteOnSend,
|
||||
attachmentFolder: () => settings.attachmentFolder,
|
||||
clipFolder: () => settings.clipFolder,
|
||||
clipFilenameTemplate: () => settings.clipFilenameTemplate,
|
||||
clipTags: () => settings.clipTags,
|
||||
archiveExportEnabled: () => settings.archiveExportEnabled,
|
||||
archiveExportSettings: () => ({
|
||||
archiveExportFolderTemplate: settings.archiveExportFolderTemplate,
|
||||
|
|
|
|||
|
|
@ -54,8 +54,12 @@ export class Notice {
|
|||
}
|
||||
}
|
||||
|
||||
export async function requestUrl(_request: unknown): Promise<{ text: string }> {
|
||||
return { text: "" };
|
||||
export function htmlToMarkdown(html: string | HTMLElement | Document | DocumentFragment): string {
|
||||
return typeof html === "string" ? html : "";
|
||||
}
|
||||
|
||||
export async function requestUrl(_request: unknown): Promise<{ status: number; text: string }> {
|
||||
return { status: 200, text: "" };
|
||||
}
|
||||
|
||||
export function prepareFuzzySearch(query: string): (text: string) => { score: number; matches: unknown[] } | null {
|
||||
|
|
|
|||
|
|
@ -70,10 +70,6 @@ describe("settings tab", () => {
|
|||
"Scroll conversation from composer line edges",
|
||||
"Reference active file on send",
|
||||
"Attachment folder",
|
||||
"Web clipping",
|
||||
"Clipped note folder",
|
||||
"Clipped note filename",
|
||||
"Clipped note tags",
|
||||
"Thread archiving",
|
||||
"Save note by default",
|
||||
"Saved note folder",
|
||||
|
|
@ -268,33 +264,6 @@ describe("settings tab", () => {
|
|||
expect(settingDesc(tab, "Attachment folder")).toContain("pasted or dropped");
|
||||
});
|
||||
|
||||
it("saves web clip settings", async () => {
|
||||
const saveSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const tab = newSettingsTab({ saveSettings });
|
||||
|
||||
tab.display();
|
||||
const folder = inputForSetting(tab, "Clipped note folder");
|
||||
const filename = inputForSetting(tab, "Clipped note filename");
|
||||
const tags = inputForSetting(tab, "Clipped note tags");
|
||||
if (!folder || !filename || !tags) throw new Error("Missing clip controls");
|
||||
expect(folder.type).toBe("text");
|
||||
expect(filename.type).toBe("text");
|
||||
expect(tags.type).toBe("text");
|
||||
|
||||
folder.value = "Web Clips";
|
||||
folder.dispatchEvent(new Event("blur"));
|
||||
filename.value = "{{site}} {{title}}.md";
|
||||
filename.dispatchEvent(new Event("blur"));
|
||||
tags.value = "web, clipping";
|
||||
tags.dispatchEvent(new Event("blur"));
|
||||
await flushPromises();
|
||||
|
||||
expect(saveSettings).toHaveBeenCalledTimes(3);
|
||||
expect(settingDesc(tab, "Web clipping")).toBe("");
|
||||
expect(settingDesc(tab, "Clipped note filename")).toContain("{{domain}}");
|
||||
expect(settingDesc(tab, "Clipped note tags")).toContain("Comma-separated");
|
||||
});
|
||||
|
||||
it("saves archive export settings", async () => {
|
||||
const saveSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const tab = newSettingsTab({ saveSettings });
|
||||
|
|
|
|||
|
|
@ -49,9 +49,6 @@ describe("settings", () => {
|
|||
scrollThreadFromComposerEdges: true,
|
||||
referenceActiveNoteOnSend: true,
|
||||
attachmentFolder: "Codex Uploads",
|
||||
clipFolder: "Codex Clippings",
|
||||
clipFilenameTemplate: "{{title}}.md",
|
||||
clipTags: "web, clipping",
|
||||
archiveExportEnabled: true,
|
||||
archiveExportFolderTemplate: "Codex Archives/{{date}}",
|
||||
archiveExportFilenameTemplate: "{{title}} {{shortId}}.md",
|
||||
|
|
@ -78,9 +75,6 @@ describe("settings", () => {
|
|||
scrollThreadFromComposerEdges: true,
|
||||
referenceActiveNoteOnSend: true,
|
||||
attachmentFolder: "Codex Attachments",
|
||||
clipFolder: "Codex Clippings",
|
||||
clipFilenameTemplate: "{{title}}.md",
|
||||
clipTags: "web, clipping",
|
||||
archiveExportEnabled: true,
|
||||
archiveExportFolderTemplate: "Codex Archives",
|
||||
archiveExportFilenameTemplate: "{{date}} {{time}} {{title}} {{shortId}}.md",
|
||||
|
|
@ -146,31 +140,6 @@ describe("settings", () => {
|
|||
expect(normalizeSettings({ attachmentFolder: 1 }).attachmentFolder).toBe(DEFAULT_SETTINGS.attachmentFolder);
|
||||
});
|
||||
|
||||
it("normalizes web clip settings", () => {
|
||||
expect(
|
||||
normalizeSettings({
|
||||
clipFolder: " Web/Clips ",
|
||||
clipFilenameTemplate: " {{site}} - {{title}}.md ",
|
||||
clipTags: " #web, clipping ",
|
||||
}),
|
||||
).toMatchObject({
|
||||
clipFolder: "Web/Clips",
|
||||
clipFilenameTemplate: "{{site}} - {{title}}.md",
|
||||
clipTags: "#web, clipping",
|
||||
});
|
||||
expect(
|
||||
normalizeSettings({
|
||||
clipFolder: " ",
|
||||
clipFilenameTemplate: " ",
|
||||
clipTags: 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
clipFolder: DEFAULT_SETTINGS.clipFolder,
|
||||
clipFilenameTemplate: DEFAULT_SETTINGS.clipFilenameTemplate,
|
||||
clipTags: DEFAULT_SETTINGS.clipTags,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes archive export settings", () => {
|
||||
expect(
|
||||
normalizeSettings({
|
||||
|
|
|
|||
Loading…
Reference in a new issue