mirror of
https://github.com/echore/vault-autopilot.git
synced 2026-07-22 08:34:00 +00:00
When a frame-selector API key is set, hook/keyframe send the extension's deduped candidate frames and a target count; the plugin asks the vision model to keep the most effect-heavy frames (skip talking-head shots) and stores only those. Robust index parsing + heuristic fallback (even-sample) when unconfigured or on any failure. New "AI 挑帧" settings (Z.ai base URL + GLM-4.6V-Flash prefilled; user supplies the key). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
372 lines
19 KiB
TypeScript
372 lines
19 KiB
TypeScript
import * as crypto from 'crypto';
|
||
import { App, Notice, PluginSettingTab, Setting } from 'obsidian';
|
||
import type VaultAutopilotPlugin from './main';
|
||
import { PluginSettings, ProviderConfig, ScreenshotClipRule, ThumbnailClipRule, WatchRule } from './types';
|
||
|
||
export const DEFAULT_SETTINGS: PluginSettings = {
|
||
rules: [],
|
||
providers: [],
|
||
httpServer: {
|
||
enabled: true,
|
||
port: 27183,
|
||
},
|
||
clipRules: {
|
||
thumbnail: { sopPath: '', outputFolder: 'Content Creation/Great Videos', thumbnailFolder: 'Assets/Great Videos', providerId: '', processingMode: 'manual' },
|
||
screenshot: { sopPath: '', outputFolder: '', providerId: '', processingMode: 'manual', framesFolder: 'Assets/images' },
|
||
hook: { sopPath: '', outputFolder: '', providerId: '', processingMode: 'manual', maxFrames: 5, framesFolder: 'Assets/images' },
|
||
keyframe: { sopPath: '', outputFolder: '', providerId: '', processingMode: 'manual', maxFrames: 5, framesFolder: 'Assets/images' },
|
||
},
|
||
frameSelector: { baseUrl: 'https://api.z.ai/api/paas/v4/', model: 'GLM-4.6V-Flash', apiKey: '' },
|
||
};
|
||
|
||
export class VaultAutopilotSettingTab extends PluginSettingTab {
|
||
constructor(app: App, private plugin: VaultAutopilotPlugin) {
|
||
super(app, plugin);
|
||
}
|
||
|
||
display(): void {
|
||
const { containerEl } = this;
|
||
containerEl.empty();
|
||
|
||
// ── HTTP Server ────────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName('HTTP Server').setHeading();
|
||
|
||
new Setting(containerEl)
|
||
.setName('Enable HTTP server')
|
||
.setDesc('Allows Chrome extension and other tools to drop files via POST /clip')
|
||
.addToggle(t => t.setValue(this.plugin.settings.httpServer.enabled).onChange(async v => {
|
||
this.plugin.settings.httpServer.enabled = v;
|
||
await this.plugin.saveSettings();
|
||
this.plugin.restartServer();
|
||
}));
|
||
|
||
new Setting(containerEl)
|
||
.setName('Port')
|
||
.setDesc('Default: 27183. Restart Obsidian after changing.')
|
||
.addText(t => t.setValue(String(this.plugin.settings.httpServer.port)).onChange(async v => {
|
||
const n = parseInt(v, 10);
|
||
if (n > 1024 && n < 65536) { this.plugin.settings.httpServer.port = n; await this.plugin.saveSettings(); }
|
||
}));
|
||
|
||
// ── AI Frame Selection ───────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName('AI 挑帧(可选)').setHeading();
|
||
new Setting(containerEl)
|
||
.setDesc('填了 API Key 后,Hook/关键帧会让视觉模型从候选帧里挑最有动效的几张、跳过口播脸;留空则用启发式(去黑+均匀取)。');
|
||
new Setting(containerEl)
|
||
.setName('API Key')
|
||
.setDesc('Z.ai / 智谱 等 OpenAI 兼容平台的 key。')
|
||
.addText(t => { t.inputEl.type = 'password'; t.setValue(this.plugin.settings.frameSelector.apiKey).onChange(async v => {
|
||
this.plugin.settings.frameSelector.apiKey = v.trim(); await this.plugin.saveSettings();
|
||
}); });
|
||
new Setting(containerEl)
|
||
.setName('Base URL')
|
||
.setDesc('OpenAI 兼容端点。Z.ai 默认:https://api.z.ai/api/paas/v4/')
|
||
.addText(t => t.setValue(this.plugin.settings.frameSelector.baseUrl).onChange(async v => {
|
||
this.plugin.settings.frameSelector.baseUrl = v.trim(); await this.plugin.saveSettings();
|
||
}));
|
||
new Setting(containerEl)
|
||
.setName('Model')
|
||
.setDesc('视觉模型名,默认 GLM-4.6V-Flash。')
|
||
.addText(t => t.setValue(this.plugin.settings.frameSelector.model).onChange(async v => {
|
||
this.plugin.settings.frameSelector.model = v.trim(); await this.plugin.saveSettings();
|
||
}));
|
||
|
||
// ── Providers ──────────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName('AI Providers').setHeading();
|
||
new Setting(containerEl)
|
||
.setName('Add provider')
|
||
.setDesc('Configure at least one provider before creating rules.')
|
||
.addDropdown(d => d
|
||
.addOption('cli-claude', 'CLI: claude (Claude Code subscription)')
|
||
.addOption('cli-gemini', 'CLI: gemini (Google account, free)')
|
||
.addOption('cli-codex', 'CLI: codex (ChatGPT Plus subscription)')
|
||
.addOption('openai-compat', 'API: OpenAI-compatible (OpenRouter / OpenAI / Grok / DeepSeek / Ollama)')
|
||
.addOption('anthropic', 'API: Anthropic (Claude API)')
|
||
.addOption('gemini-api', 'API: Google Gemini API')
|
||
)
|
||
.addButton(b => b.setButtonText('Add').onClick(async () => {
|
||
const sel = containerEl.querySelector('select') as HTMLSelectElement;
|
||
if (!sel) return;
|
||
const type = sel.value;
|
||
const id = crypto.randomBytes(4).toString('hex');
|
||
let newProvider: ProviderConfig;
|
||
if (type === 'openai-compat') {
|
||
newProvider = { id, type: 'openai-compat', label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', apiKey: '', model: '' };
|
||
} else if (type === 'anthropic') {
|
||
newProvider = { id, type: 'anthropic', apiKey: '', model: 'claude-sonnet-4-6' };
|
||
} else if (type === 'gemini-api') {
|
||
newProvider = { id, type: 'gemini-api', apiKey: '', model: 'gemini-1.5-flash' };
|
||
} else {
|
||
const cliType = type.replace('cli-', '') as 'claude' | 'gemini' | 'codex';
|
||
newProvider = { id, type: 'cli', cliType, bin: '' };
|
||
}
|
||
this.plugin.settings.providers.push(newProvider);
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
}));
|
||
|
||
for (const [i, prov] of this.plugin.settings.providers.entries()) {
|
||
this.renderProvider(containerEl, prov, i);
|
||
}
|
||
|
||
// ── Rules ──────────────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName('Watch Rules').setHeading();
|
||
new Setting(containerEl)
|
||
.setName('Add rule')
|
||
.addButton(b => b.setButtonText('Add rule').onClick(async () => {
|
||
const id = crypto.randomBytes(4).toString('hex');
|
||
this.plugin.settings.rules.push({ id, enabled: true, watchFolder: '', sopPath: '', outputFolder: '', providerId: this.plugin.settings.providers[0]?.id || '' });
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
}));
|
||
|
||
for (const [i, rule] of this.plugin.settings.rules.entries()) {
|
||
this.renderRule(containerEl, rule, i);
|
||
}
|
||
|
||
// ── Clip Rules ──────────────────────────────────────────────────────────────
|
||
new Setting(containerEl).setName('Clip Rules').setHeading();
|
||
|
||
new Setting(containerEl).setName('Thumbnail / Great Videos').setHeading();
|
||
this.renderThumbnailClipRule(containerEl);
|
||
|
||
new Setting(containerEl).setName('Screenshot').setHeading();
|
||
this.renderScreenshotClipRule(containerEl);
|
||
|
||
for (const mode of ['hook', 'keyframe'] as const) {
|
||
const label = mode === 'hook' ? 'Hook Analysis' : 'Keyframe Analysis';
|
||
new Setting(containerEl).setName(label).setHeading();
|
||
new Setting(containerEl)
|
||
.setName('Processing mode')
|
||
.setDesc('Auto: vault-autopilot calls AI and writes the note. Manual: saves frames + template, you trigger analysis in Obsidian.')
|
||
.addDropdown(d => d
|
||
.addOption('manual', 'Manual (save frames + template)')
|
||
.addOption('auto', 'Auto (call AI provider)')
|
||
.setValue(this.plugin.settings.clipRules[mode].processingMode)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules[mode].processingMode = v as 'auto' | 'manual';
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
}));
|
||
new Setting(containerEl)
|
||
.setName('Max frames to save')
|
||
.setDesc('How many frames to sample and save (1–20). Default: 5.')
|
||
.addText(t => t
|
||
.setValue(String(this.plugin.settings.clipRules[mode].maxFrames))
|
||
.onChange(async v => {
|
||
const n = parseInt(v, 10);
|
||
if (n >= 1 && n <= 20) {
|
||
this.plugin.settings.clipRules[mode].maxFrames = n;
|
||
await this.plugin.saveSettings();
|
||
}
|
||
}));
|
||
if (this.plugin.settings.clipRules[mode].processingMode === 'manual') {
|
||
new Setting(containerEl)
|
||
.setName('Frames folder')
|
||
.setDesc('Vault-relative path where frame images are saved. Default: Assets/images')
|
||
.addText(t => t
|
||
.setValue(this.plugin.settings.clipRules[mode].framesFolder)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules[mode].framesFolder = v.trim();
|
||
await this.plugin.saveSettings();
|
||
}));
|
||
}
|
||
new Setting(containerEl)
|
||
.setName('SOP / prompt path')
|
||
.setDesc('Absolute path to the markdown SOP file.')
|
||
.addText(t => t
|
||
.setValue(this.plugin.settings.clipRules[mode].sopPath)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules[mode].sopPath = v.trim();
|
||
await this.plugin.saveSettings();
|
||
}));
|
||
new Setting(containerEl)
|
||
.setName('Output folder')
|
||
.setDesc('Vault-relative path. e.g. Notes/Hooks')
|
||
.addText(t => t
|
||
.setValue(this.plugin.settings.clipRules[mode].outputFolder)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules[mode].outputFolder = v.trim();
|
||
await this.plugin.saveSettings();
|
||
}));
|
||
if (this.plugin.settings.clipRules[mode].processingMode === 'auto') {
|
||
new Setting(containerEl)
|
||
.setName('Provider')
|
||
.setDesc('Must be an API provider (Anthropic, OpenAI-compatible, or Gemini).')
|
||
.addDropdown(d => {
|
||
this.plugin.settings.providers.forEach(p =>
|
||
d.addOption(p.id, p.type === 'cli' ? `CLI: ${(p as any).cliType}` : (p as any).label || p.type)
|
||
);
|
||
d.setValue(this.plugin.settings.clipRules[mode].providerId)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules[mode].providerId = v;
|
||
await this.plugin.saveSettings();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
private renderThumbnailClipRule(el: HTMLElement): void {
|
||
const rule: ThumbnailClipRule = this.plugin.settings.clipRules.thumbnail;
|
||
new Setting(el)
|
||
.setName('Processing mode')
|
||
.setDesc('Auto: downloads thumbnail + calls AI to analyze cover. Manual: downloads thumbnail + generates note template.')
|
||
.addDropdown(d => d
|
||
.addOption('manual', 'Manual (download + template)')
|
||
.addOption('auto', 'Auto (download + call AI provider)')
|
||
.setValue(rule.processingMode)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules.thumbnail.processingMode = v as 'auto' | 'manual';
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
}));
|
||
new Setting(el)
|
||
.setName('Output folder')
|
||
.setDesc('Vault-relative path for generated notes. Default: Content Creation/Great Videos')
|
||
.addText(t => t
|
||
.setValue(rule.outputFolder)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules.thumbnail.outputFolder = v.trim();
|
||
await this.plugin.saveSettings();
|
||
}));
|
||
new Setting(el)
|
||
.setName('Thumbnail folder')
|
||
.setDesc('Vault-relative path for downloaded thumbnail images. Default: Assets/Great Videos')
|
||
.addText(t => t
|
||
.setValue(rule.thumbnailFolder)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules.thumbnail.thumbnailFolder = v.trim();
|
||
await this.plugin.saveSettings();
|
||
}));
|
||
new Setting(el)
|
||
.setName('SOP / prompt path')
|
||
.setDesc('Absolute path to the markdown SOP file.')
|
||
.addText(t => t
|
||
.setValue(rule.sopPath)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules.thumbnail.sopPath = v.trim();
|
||
await this.plugin.saveSettings();
|
||
}));
|
||
if (rule.processingMode === 'auto') {
|
||
new Setting(el)
|
||
.setName('Provider')
|
||
.setDesc('Must be an API provider (Anthropic, OpenAI-compatible, or Gemini).')
|
||
.addDropdown(d => {
|
||
this.plugin.settings.providers.forEach(p =>
|
||
d.addOption(p.id, p.type === 'cli' ? `CLI: ${(p as any).cliType}` : (p as any).label || p.type)
|
||
);
|
||
d.setValue(rule.providerId)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules.thumbnail.providerId = v;
|
||
await this.plugin.saveSettings();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
private renderScreenshotClipRule(el: HTMLElement): void {
|
||
const rule: ScreenshotClipRule = this.plugin.settings.clipRules.screenshot;
|
||
new Setting(el)
|
||
.setName('Processing mode')
|
||
.setDesc('Auto: vault-autopilot calls AI and writes the note. Manual: saves images + template, you trigger analysis in Obsidian.')
|
||
.addDropdown(d => d
|
||
.addOption('manual', 'Manual (save images + template)')
|
||
.addOption('auto', 'Auto (call AI provider)')
|
||
.setValue(rule.processingMode)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules.screenshot.processingMode = v as 'auto' | 'manual';
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
}));
|
||
new Setting(el)
|
||
.setName('Frames folder')
|
||
.setDesc('Vault-relative path where screenshot images are saved. Default: Assets/images')
|
||
.addText(t => t
|
||
.setValue(rule.framesFolder)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules.screenshot.framesFolder = v.trim();
|
||
await this.plugin.saveSettings();
|
||
}));
|
||
new Setting(el)
|
||
.setName('SOP / prompt path')
|
||
.setDesc('Absolute path to the markdown SOP file.')
|
||
.addText(t => t
|
||
.setValue(rule.sopPath)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules.screenshot.sopPath = v.trim();
|
||
await this.plugin.saveSettings();
|
||
}));
|
||
new Setting(el)
|
||
.setName('Output folder')
|
||
.setDesc('Vault-relative path. e.g. Notes/Screenshots')
|
||
.addText(t => t
|
||
.setValue(rule.outputFolder)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules.screenshot.outputFolder = v.trim();
|
||
await this.plugin.saveSettings();
|
||
}));
|
||
if (rule.processingMode === 'auto') {
|
||
new Setting(el)
|
||
.setName('Provider')
|
||
.setDesc('Must be an API provider (Anthropic, OpenAI-compatible, or Gemini).')
|
||
.addDropdown(d => {
|
||
this.plugin.settings.providers.forEach(p =>
|
||
d.addOption(p.id, p.type === 'cli' ? `CLI: ${(p as any).cliType}` : (p as any).label || p.type)
|
||
);
|
||
d.setValue(rule.providerId)
|
||
.onChange(async v => {
|
||
this.plugin.settings.clipRules.screenshot.providerId = v;
|
||
await this.plugin.saveSettings();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
private renderProvider(el: HTMLElement, prov: ProviderConfig, i: number): void {
|
||
const label = prov.type === 'cli' ? `CLI: ${prov.cliType}` : prov.type === 'openai-compat' ? `API: ${prov.label}` : `API: ${prov.type}`;
|
||
new Setting(el).setName(label).setHeading();
|
||
|
||
if (prov.type === 'cli') {
|
||
new Setting(el)
|
||
.setName('Binary path')
|
||
.setDesc(`Leave empty to auto-detect. Example: /usr/local/bin/${prov.cliType}`)
|
||
.addText(t => t.setValue(prov.bin).onChange(async v => {
|
||
(this.plugin.settings.providers[i] as any).bin = v.trim();
|
||
await this.plugin.saveSettings();
|
||
}));
|
||
}
|
||
if (prov.type === 'openai-compat') {
|
||
new Setting(el).setName('Label').addText(t => t.setValue(prov.label).onChange(async v => { (this.plugin.settings.providers[i] as any).label = v; await this.plugin.saveSettings(); }));
|
||
new Setting(el).setName('Base URL').setDesc('e.g. https://openrouter.ai/api/v1').addText(t => t.setValue(prov.baseUrl).onChange(async v => { (this.plugin.settings.providers[i] as any).baseUrl = v.trim(); await this.plugin.saveSettings(); }));
|
||
new Setting(el).setName('API Key').addText(t => t.setValue(prov.apiKey).onChange(async v => { (this.plugin.settings.providers[i] as any).apiKey = v.trim(); await this.plugin.saveSettings(); }));
|
||
new Setting(el).setName('Model').setDesc('Must support vision for image analysis. e.g. gpt-4o, openai/gpt-4o').addText(t => t.setValue(prov.model).onChange(async v => { (this.plugin.settings.providers[i] as any).model = v.trim(); await this.plugin.saveSettings(); }));
|
||
}
|
||
if (prov.type === 'anthropic' || prov.type === 'gemini-api') {
|
||
new Setting(el).setName('API Key').addText(t => t.setValue(prov.apiKey).onChange(async v => { (this.plugin.settings.providers[i] as any).apiKey = v.trim(); await this.plugin.saveSettings(); }));
|
||
new Setting(el).setName('Model').addText(t => t.setValue(prov.model).onChange(async v => { (this.plugin.settings.providers[i] as any).model = v.trim(); await this.plugin.saveSettings(); }));
|
||
}
|
||
new Setting(el).addButton(b => b.setButtonText('Remove provider').onClick(async () => {
|
||
this.plugin.settings.providers.splice(i, 1);
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
}));
|
||
}
|
||
|
||
private renderRule(el: HTMLElement, rule: WatchRule, i: number): void {
|
||
new Setting(el).setName(`Rule ${i + 1}`).setHeading();
|
||
new Setting(el).setName('Watch folder').setDesc('Vault-relative. e.g. Inbox/screenshots').addText(t => t.setValue(rule.watchFolder).onChange(async v => { this.plugin.settings.rules[i].watchFolder = v.trim(); await this.plugin.saveSettings(); }));
|
||
new Setting(el).setName('SOP / prompt path').setDesc('Absolute path to the markdown file with instructions.').addText(t => t.setValue(rule.sopPath).onChange(async v => { this.plugin.settings.rules[i].sopPath = v.trim(); await this.plugin.saveSettings(); }));
|
||
new Setting(el).setName('Output folder').setDesc('Vault-relative. e.g. Notes/Aesthetic').addText(t => t.setValue(rule.outputFolder).onChange(async v => { this.plugin.settings.rules[i].outputFolder = v.trim(); await this.plugin.saveSettings(); }));
|
||
new Setting(el).setName('Provider').addDropdown(d => {
|
||
this.plugin.settings.providers.forEach(p => d.addOption(p.id, p.type === 'cli' ? `CLI: ${(p as any).cliType}` : (p as any).label || p.type));
|
||
d.setValue(rule.providerId).onChange(async v => { this.plugin.settings.rules[i].providerId = v; await this.plugin.saveSettings(); });
|
||
});
|
||
new Setting(el).setName('Enabled').addToggle(t => t.setValue(rule.enabled).onChange(async v => { this.plugin.settings.rules[i].enabled = v; await this.plugin.saveSettings(); }));
|
||
new Setting(el).addButton(b => b.setButtonText('Remove rule').onClick(async () => {
|
||
this.plugin.settings.rules.splice(i, 1);
|
||
await this.plugin.saveSettings();
|
||
this.display();
|
||
}));
|
||
}
|
||
}
|