logancyang_obsidian-copilot/src/components/modals/project/AddProjectModal.tsx
Zero Liu 89823e1d03
feat(chat): power legacy chat from the model-management chat backend (#2556)
* feat(chat): power legacy chat from the model-management "chat" backend

Legacy chat, project chat, vault QA, quick command, and quick ask now
select models from the model-management "chat" backend
(backends.chat.enabledModels) instead of the legacy activeModels /
defaultModelKey. A new ConfiguredModel→CustomModel bridge feeds the
existing ChatModelManager (the stubbed v4 ChatModelFactory is untouched),
and a new "Quick Chat" curation section under the Agents settings tab
lets users choose which BYOK/Plus models appear in the chat picker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(skills): apply prettier formatting to builtinSkills.test.ts

Collapse a multi-line expect() onto one line to satisfy the project's
prettier config (surfaced when running npm run format).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(chat): stabilize chat backend model selections

* fix(chat): address chat backend review regressions

* fix(chat): show legacy chat models in a flat list

Drop the per-provider _group on chat picker entries so the legacy chat
model picker renders a single flat list (provider icons inline, no
section headers), matching pre-migration behavior. Agent Mode keeps its
per-backend headers via its own helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(chat): remove dead per-model parameter editing

After the model-management chat migration, per-model parameter editing
is unreachable: the chat selection is now a configuredModelId UUID, so
the ChatSettingsPopover editor (gated on a legacy activeModels match)
never renders, and TokenLimitWarning's activeModels lookup always misses
and falls back to global settings. v4 agent mode no longer supports
per-model parameter editing.

- ChatSettingsPopover: drop the model-param plumbing (originalModel /
  bridgedModel / canEditModelParameters, local-model save/debounce,
  ModelParametersEditor); keep System Prompt + Disable Builtin controls.
- TokenLimitWarning: always open the global Copilot settings tab.
- Delete the now-orphaned ModelEditDialog and ModelParametersEditor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(chat): drop dead legacy model-key fallback in streaming session

The streaming chat session's chain cache key fell back to name|provider
when configuredModelId was absent. Both callers (Quick Ask, custom-command
chat) now source their model from useResolvedChatBackendModel ->
configuredModelToCustomModel, which always stamps configuredModelId, so the
fallback was unreachable. Derive the key directly from configuredModelId;
a model without one is treated as no usable model via the existing guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(chat): remove test-only getLegacyChatModelKey helper

The singular getLegacyChatModelKey was exported but had no production
callers — only the barrel re-export and one test assertion referenced it.
Remove it (and its export + assertion); the load-bearing plural
getLegacyChatModelKeys, which powers legacy-key resolution in
isChatModelSelectionForEntry, is kept and now carries the rationale comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(model-management): dedupe catalog-id to provider map

Drop the duplicate CATALOG_ID_TO_LEGACY_PROVIDER table in chatModelSelection
and reuse the now-exported CATALOG_ID_TO_CHAT_PROVIDER from
configuredModelToCustomModel (extended with anthropic/google for the
legacy-key path). Single source of truth for catalog-id to ChatModelProviders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:52:50 -07:00

488 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ProjectConfig } from "@/aiParams";
import { useApp } from "@/context";
import { ContextManageModal } from "@/components/modals/project/context-manage-modal";
import { openCachedItemPreview } from "@/utils/cacheFileOpener";
import type { ProcessingItem } from "@/components/project/processingAdapter";
import { ProcessingStatus } from "@/components/project/processing-status";
import { useProjectProcessingData } from "@/components/project/useProjectProcessingData";
import { Button } from "@/components/ui/button";
import { FormField } from "@/components/ui/form-field";
import { HelpTooltip } from "@/components/ui/help-tooltip";
import { Input } from "@/components/ui/input";
import { ObsidianNativeSelect } from "@/components/ui/obsidian-native-select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { SettingSlider } from "@/components/ui/setting-slider";
import { Textarea } from "@/components/ui/textarea";
import { UrlTagInput } from "@/components/ui/url-tag-input";
import { SystemPromptSyntaxInstruction } from "@/components/SystemPromptSyntaxInstruction";
import { DEFAULT_MODEL_SETTING } from "@/constants";
import { ProjectContextBadgeList } from "@/components/project/ProjectContextBadgeList";
import { err2String, randomUUID } from "@/utils";
import { Settings } from "lucide-react";
import { type UrlItem, parseProjectUrls, serializeProjectUrls } from "@/utils/urlTagUtils";
import type CopilotPlugin from "@/main";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { useChatBackendModelOptions } from "@/hooks/useChatBackendModelOptions";
import { App, Modal, Notice } from "obsidian";
import React, { useMemo, useState } from "react";
import { Root } from "react-dom/client";
interface AddProjectModalContentProps {
initialProject?: ProjectConfig;
onSave: (project: ProjectConfig) => Promise<void>;
onCancel: () => void;
plugin?: CopilotPlugin;
}
function AddProjectModalContent({
initialProject,
onSave,
onCancel,
plugin,
}: AddProjectModalContentProps) {
const app = useApp();
// Project model options come from the model-management "chat" backend
// (same enabled set as Quick Chat), keyed by configuredModelId.
const { options: chatModelOptions, resolveSelectionId } = useChatBackendModelOptions();
const [isSubmitting, setIsSubmitting] = useState(false);
const [touched, setTouched] = useState({
name: false,
systemPrompt: false,
projectModelKey: false,
inclusions: false,
});
const [formData, setFormData] = useState<ProjectConfig>(() =>
initialProject
? {
...initialProject,
projectModelKey:
resolveSelectionId(initialProject.projectModelKey) || initialProject.projectModelKey,
}
: {
id: randomUUID(),
name: "",
description: "",
systemPrompt: "",
projectModelKey: "",
modelConfigs: {
temperature: DEFAULT_MODEL_SETTING.TEMPERATURE,
maxTokens: DEFAULT_MODEL_SETTING.MAX_TOKENS,
},
contextSource: {
inclusions: "",
exclusions: "",
webUrls: "",
youtubeUrls: "",
},
created: Date.now(),
UsageTimestamps: Date.now(),
}
);
// URL items derived from formData for UrlTagInput
const urlItems = useMemo(
() =>
parseProjectUrls(
formData.contextSource?.webUrls || "",
formData.contextSource?.youtubeUrls || ""
),
[formData.contextSource?.webUrls, formData.contextSource?.youtubeUrls]
);
// Reason: Shared hook handles cache loading, file enumeration, and processingData construction.
// contextSource draft is passed so newly added (unsaved) URLs appear as "Pending".
const { processingData, projectCache, isCurrentProject } = useProjectProcessingData({
cacheProject: initialProject ?? null,
contextSource: formData.contextSource,
});
const handleEditProjectContext = (projectDraft: ProjectConfig) => {
const modal = new ContextManageModal(
app,
(updatedProject: ProjectConfig) => {
// Reason: Only merge inclusions/exclusions (what ContextManageModal edits).
// Don't replace the entire contextSource — that would overwrite any
// webUrls/youtubeUrls changes the user made in AddProjectModal while
// the child modal was open.
setFormData((prev) => ({
...prev,
contextSource: {
...prev.contextSource,
inclusions: updatedProject.contextSource?.inclusions,
exclusions: updatedProject.contextSource?.exclusions,
},
}));
},
projectDraft
);
modal.open();
};
const isFormValid = () => {
return formData.name && formData.projectModelKey;
};
const handleInputChange = (
field: string,
value: string | number | string[] | Record<string, unknown>
) => {
setFormData((prev) => {
if (typeof value === "string") {
if (field === "projectModelKey") {
value = value.trim();
}
}
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
value = value.map((item) => item.trim()).filter(Boolean);
}
if (field.includes(".")) {
const [parent, child] = field.split(".");
const parentKey = parent as keyof typeof prev;
const parentValue = prev[parentKey];
if (typeof parentValue === "object" && parentValue !== null) {
return {
...prev,
[parent]: {
...parentValue,
[child]: value,
},
};
}
}
return {
...prev,
[field]: value,
};
});
};
/** Handle URL adds from UrlTagInput, serialize back to formData strings */
const handleUrlAdd = (newUrls: UrlItem[]) => {
const allUrls = [...urlItems, ...newUrls];
const { webUrls, youtubeUrls } = serializeProjectUrls(allUrls);
handleInputChange("contextSource.webUrls", webUrls);
handleInputChange("contextSource.youtubeUrls", youtubeUrls);
};
/** Handle URL removal from UrlTagInput */
const handleUrlRemove = (id: string) => {
const remaining = urlItems.filter((u) => u.id !== id);
const { webUrls, youtubeUrls } = serializeProjectUrls(remaining);
handleInputChange("contextSource.webUrls", webUrls);
handleInputChange("contextSource.youtubeUrls", youtubeUrls);
};
/** Handle inclusions pattern changes from badge list deletion */
const handleInclusionsChange = (value: string) => {
handleInputChange("contextSource.inclusions", value);
};
/** Handle exclusions pattern changes from badge list deletion */
const handleExclusionsChange = (value: string) => {
handleInputChange("contextSource.exclusions", value);
};
/** Handle opening cached parsed content for any item (file or URL) */
const handleOpenCachedItem = (item: ProcessingItem) => {
void openCachedItemPreview(app, projectCache, item);
};
/** Handle removing a failed URL from the project config via ProcessingStatus × button */
const handleRemoveUrl = (item: ProcessingItem) => {
// Reason: ProcessingItem.id is the raw URL, while UrlItem.id is "type:url".
// Match by url field and cacheKind to avoid ID format mismatch.
const targetType = item.cacheKind === "youtube" ? "youtube" : "web";
const remaining = urlItems.filter((u) => !(u.type === targetType && u.url === item.id));
const { webUrls, youtubeUrls } = serializeProjectUrls(remaining);
handleInputChange("contextSource.webUrls", webUrls);
handleInputChange("contextSource.youtubeUrls", youtubeUrls);
};
/** Handle retry for failed processing items */
const handleRetry = (itemId: string) => {
if (!plugin?.projectManager || !processingData) return;
const failedItem = processingData.failedItemMap.get(itemId);
if (failedItem) {
void plugin.projectManager.retryFailedItem(failedItem);
}
};
const handleSave = async () => {
const trimmedName = formData.name?.trim() ?? "";
const saveData = { ...formData, name: trimmedName };
const requiredFields = ["name", "projectModelKey"];
const missingFields = requiredFields.filter((field) => !saveData[field as keyof ProjectConfig]);
if (missingFields.length > 0) {
setTouched((prev) => ({
...prev,
...Object.fromEntries(missingFields.map((field) => [field, true])),
}));
new Notice("Please fill in all required fields");
return;
}
try {
setIsSubmitting(true);
await onSave(saveData);
} catch (e) {
new Notice(err2String(e));
setTouched((prev) => ({
...prev,
name: true,
}));
} finally {
setIsSubmitting(false);
}
};
return (
<div className="tw-flex tw-h-full tw-flex-col">
{/* Header */}
<div className="tw-shrink-0 tw-px-4 tw-pb-2 tw-pt-4">
<div className="tw-text-xl tw-font-bold tw-text-normal">
{initialProject ? "Edit Project" : "New Project"}
</div>
<p className="tw-mt-1 tw-text-sm tw-text-muted">
Configure your project settings and context sources
</p>
</div>
{/* Scrollable Content */}
<ScrollArea className="tw-min-h-0 tw-flex-1">
<div className="tw-flex tw-flex-col tw-gap-6 tw-p-4">
{/* Basic Info Card */}
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4 tw-bg-secondary/50">
<h3 className="tw-mb-3 tw-text-sm tw-font-medium tw-text-normal">Basic Info</h3>
<div className="tw-flex tw-flex-col tw-gap-3">
<FormField
label="Project Name"
required
error={touched.name && !formData.name}
errorMessage="Project name is required"
>
<Input
type="text"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
onBlur={() => setTouched((prev) => ({ ...prev, name: true }))}
className="tw-w-full"
/>
</FormField>
<FormField
label="Description"
description="Briefly describe the purpose and goals of the project"
>
<Input
type="text"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
className="tw-w-full"
/>
</FormField>
<FormField
label="Project System Prompt"
description="Custom instructions for how the AI should behave in this project context"
>
<SystemPromptSyntaxInstruction />
<Textarea
value={formData.systemPrompt}
onChange={(e) => handleInputChange("systemPrompt", e.target.value)}
onBlur={() => setTouched((prev) => ({ ...prev, systemPrompt: true }))}
placeholder="Enter your project system prompt here... Use {[[Note Name]]} to include note contents."
className="tw-min-h-32"
/>
</FormField>
</div>
</div>
{/* Model Configuration Card */}
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4 tw-bg-secondary/50">
<h3 className="tw-mb-3 tw-text-sm tw-font-medium tw-text-normal">
Model Configuration
</h3>
<div className="tw-flex tw-flex-col tw-gap-3">
<FormField
label="Default Model"
required
error={touched.projectModelKey && !formData.projectModelKey}
errorMessage="Default model is required"
>
<ObsidianNativeSelect
value={formData.projectModelKey}
onChange={(e) => handleInputChange("projectModelKey", e.target.value)}
onBlur={() => setTouched((prev) => ({ ...prev, projectModelKey: true }))}
placeholder="Select a model"
options={chatModelOptions}
/>
</FormField>
<FormField label="Temperature">
<SettingSlider
value={formData.modelConfigs?.temperature ?? DEFAULT_MODEL_SETTING.TEMPERATURE}
onChange={(value) => handleInputChange("modelConfigs.temperature", value)}
min={0}
max={2}
step={0.01}
className="tw-w-full"
/>
</FormField>
<FormField label="Token Limit">
<SettingSlider
value={formData.modelConfigs?.maxTokens ?? DEFAULT_MODEL_SETTING.MAX_TOKENS}
onChange={(value) => handleInputChange("modelConfigs.maxTokens", value)}
min={1}
max={65000}
step={1}
className="tw-w-full"
/>
</FormField>
</div>
</div>
{/* Context Sources Card */}
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4 tw-bg-secondary/50">
<h3 className="tw-mb-3 tw-text-sm tw-font-medium tw-text-normal">Context Sources</h3>
<div className="tw-flex tw-flex-col tw-gap-4">
{/* File Context Sub-card */}
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4">
<FormField
label={
<div className="tw-flex tw-items-center tw-gap-2">
<span>File Context</span>
<HelpTooltip
buttonClassName="tw-size-4 tw-text-muted"
content={
<div className="tw-max-w-80">
<strong>Supported File Types:</strong>
<br />
<strong> Documents:</strong> pdf, doc, docx, ppt, pptx, epub, txt, rtf
and many more
<br />
<strong> Images:</strong> jpg, png, svg, gif, bmp, webp, tiff
<br />
<strong> Spreadsheets:</strong> xlsx, xls, csv, numbers
<br />
<br />
Non-markdown files are converted to markdown in the background.
<br />
<strong>Rate limit:</strong> 50 files or 100MB per 3 hours, whichever is
reached first.
</div>
}
/>
</div>
}
description="Define patterns to include specific files, folders or tags (specified in the note property) in the project context."
>
<ProjectContextBadgeList
inclusions={formData.contextSource?.inclusions}
exclusions={formData.contextSource?.exclusions}
onInclusionsChange={handleInclusionsChange}
onExclusionsChange={handleExclusionsChange}
actionSlot={
<Button
size="lg"
className="tw-h-9 tw-gap-1 tw-px-3 sm:tw-h-auto sm:tw-px-2"
onClick={() => handleEditProjectContext(formData)}
>
<Settings className="tw-size-4 sm:tw-size-3.5" />
Manage Context
</Button>
}
/>
</FormField>
</div>
{/* URLs Sub-card */}
<div className="tw-rounded-lg tw-border tw-border-border tw-p-4">
<div className="tw-mb-3">
<span className="tw-text-sm tw-font-medium tw-text-normal">URLs</span>
<p className="tw-mt-1 tw-text-ui-smaller tw-text-muted">
Add web pages or YouTube videos as context sources
</p>
</div>
<UrlTagInput urls={urlItems} onAdd={handleUrlAdd} onRemove={handleUrlRemove} />
</div>
</div>
</div>
{/* Processing Status - show for any project in edit mode (active: live state; others: cache state) */}
{initialProject && processingData && (
<ProcessingStatus
items={processingData.items}
onRetry={isCurrentProject ? handleRetry : undefined}
onOpenCachedItem={projectCache != null ? handleOpenCachedItem : undefined}
onRemoveUrl={handleRemoveUrl}
defaultExpanded={false}
maxHeight="200px"
/>
)}
</div>
</ScrollArea>
{/* Sticky Footer */}
<div className="tw-shrink-0 tw-border-t tw-border-border tw-px-4 tw-py-3">
<div className="tw-flex tw-items-center tw-justify-end tw-gap-2">
<Button variant="ghost" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleSave} disabled={isSubmitting || !isFormValid()}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</div>
</div>
</div>
);
}
export class AddProjectModal extends Modal {
private root: Root;
constructor(
app: App,
private onSave: (project: ProjectConfig) => Promise<void>,
private initialProject?: ProjectConfig,
private plugin?: CopilotPlugin
) {
super(app);
}
onOpen() {
const { contentEl, modalEl } = this;
// Reason: Ensure the modal is wide enough for card layout and tall enough for ScrollArea
modalEl.addClass("!tw-max-h-[85vh]");
this.root = createPluginRoot(contentEl, this.app);
const handleSave = async (project: ProjectConfig) => {
await this.onSave(project);
this.close();
};
const handleCancel = () => {
this.close();
};
this.root.render(
<AddProjectModalContent
initialProject={this.initialProject}
onSave={handleSave}
onCancel={handleCancel}
plugin={this.plugin}
/>
);
}
onClose() {
this.root.unmount();
}
}