mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* 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>
488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
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();
|
||
}
|
||
}
|