From 79e06a913e07f27494c2bfaa90ce8ca83c6302c6 Mon Sep 17 00:00:00 2001 From: Jee C Date: Sat, 9 May 2026 21:43:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20=E8=A1=A5=E5=85=A8=E6=B3=95?= =?UTF-8?q?=E6=9D=A1=20command=20with=20=E5=85=83=E5=85=B8=20API=20integra?= =?UTF-8?q?tion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Register legal provision lookup as an Obsidian command (addCommand) - Use configured LLM to detect law references from the 50 chars before cursor - Call Yuandian rh_ft_detail API for exact article lookup, show in sidebar panel - Two insert modes: raw provision text and LLM-adapted continuation, both as ghost text - Adapted mode uses quote-colon format, continues from cursor without repeating existing text - Spinner on "匹配原文" button during LLM rewrite; reasoning_content never exposed - Auto-remove trigger space typed before "/" to avoid ghost text misalignment - Settings v2.4.0: add Yuandian API key field with migration from 2.3.0 - New CSS classes for legal panel, item cards, action buttons, and spinner animation Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 + src/i18n/en.ts | 24 ++++ src/i18n/zh.ts | 23 ++++ src/legal/detector.ts | 4 + src/legal/slash-command.ts | 170 ++++++++++++++++++++++++++ src/legal/view.ts | 135 ++++++++++++++++++++ src/legal/yuandian-client.ts | 97 +++++++++++++++ src/main.ts | 110 +++++++---------- src/settings/index.ts | 26 +++- src/settings/migrators/2.3.0-2.4.0.ts | 26 ++++ src/settings/runner.ts | 2 + src/settings/versions/2.3.0/index.ts | 9 -- src/settings/versions/2.4.0/index.ts | 47 +++++++ styles.css | 88 +++++++++++++ 14 files changed, 689 insertions(+), 75 deletions(-) create mode 100644 src/legal/detector.ts create mode 100644 src/legal/slash-command.ts create mode 100644 src/legal/view.ts create mode 100644 src/legal/yuandian-client.ts create mode 100644 src/settings/migrators/2.3.0-2.4.0.ts create mode 100644 src/settings/versions/2.4.0/index.ts diff --git a/.gitignore b/.gitignore index b7081d6..e2dcfb1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ data.json # Exclude macOS Finder (System Explorer) View States .DS_Store +# Secrets +.env + # Internal development files CLAUDE.md AGENTS.md diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 664b9b1..b07430d 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -102,4 +102,28 @@ export const en: Record = { // About 'about.heading': 'About', + + // Legal database + 'settings.legal.title': 'Legal Database', + 'settings.legal.apiKey': 'Yuandian API Key', + 'settings.legal.apiKeyDesc': + 'Used for legal provision lookup (1 credit per exact query, 10 credits per semantic search)', + + // Legal slash command + 'legal.slashCommand.label': 'Complete legal provision', + + // Legal panel + 'legal.panel.title': 'Legal Provisions', + 'legal.panel.loading': 'Searching…', + 'legal.panel.detecting': 'Analysing context…', + 'legal.panel.fetching': 'Fetching provision…', + 'legal.panel.adapting': 'Adapting…', + 'legal.panel.insert.raw': 'Insert provision', + 'legal.panel.insert.adapted': 'Adapted', + 'legal.panel.empty': 'No results found', + 'legal.panel.error': 'Query failed. Check your API key.', + 'legal.panel.detail.label': 'Exact match', + 'legal.panel.search.label': 'Semantic results', + 'legal.notice.noApiKey': + 'Please set your Yuandian API Key in settings first.', }; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 6fcda85..2aa3e1d 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -97,4 +97,27 @@ export const zh: Record = { // About 'about.heading': '关于', + + // Legal database + 'settings.legal.title': '法律数据库', + 'settings.legal.apiKey': '元典 API Key', + 'settings.legal.apiKeyDesc': + '用于法条查询(精确查条 1 积分/次,语义检索 10 积分/次)', + + // Legal slash command + 'legal.slashCommand.label': '补全法条', + + // Legal panel + 'legal.panel.title': '法条', + 'legal.panel.loading': '查询中…', + 'legal.panel.detecting': '正在分析上下文…', + 'legal.panel.fetching': '正在查询法条…', + 'legal.panel.adapting': '正在改写…', + 'legal.panel.insert.raw': '插入法条', + 'legal.panel.insert.adapted': '匹配原文', + 'legal.panel.empty': '未找到相关法条', + 'legal.panel.error': '查询失败,请检查 API 密钥', + 'legal.panel.detail.label': '精确匹配', + 'legal.panel.search.label': '语义相关', + 'legal.notice.noApiKey': '请先在设置中填写元典 API Key', }; diff --git a/src/legal/detector.ts b/src/legal/detector.ts new file mode 100644 index 0000000..ffa7a2a --- /dev/null +++ b/src/legal/detector.ts @@ -0,0 +1,4 @@ +export interface LegalRef { + fgmc: string; + ftnum: string; +} diff --git a/src/legal/slash-command.ts b/src/legal/slash-command.ts new file mode 100644 index 0000000..85bcf03 --- /dev/null +++ b/src/legal/slash-command.ts @@ -0,0 +1,170 @@ +import { Editor, Notice, requestUrl } from 'obsidian'; +import { EditorView } from '@codemirror/view'; +import { PROVIDERS_BASE_URLS } from 'src/api/providers'; +import { setCompletionsEffect } from 'src/editor/state'; +import { t } from 'src/i18n'; +import { LegalRef } from './detector'; +import { ArticleDetail, YuandianClient } from './yuandian-client'; + +import type RosyPilot from '../main'; + +const EXTRACT_SYSTEM_PROMPT = + '从用户文本中提取最近引用的具体法条。仅返回JSON:{"fgmc":"法规名称","ftnum":"第X条"},无具体引用时返回 null。不要解释。'; + +const ADAPT_SYSTEM_PROMPT = + '你是法律写作助手。你生成的文字将直接插入在用户光标位置之后,所以不要重复光标前已有的任何文字。请直接续写光标前未完成的内容,以引号加冒号的形式(如:规定:"……")将法条最相关的原文嵌入句子中,优先引用原文,最小程度改写,风格与上文保持一致。只返回续写文字,不加任何说明。'; + +export class LegalSlashCommand { + constructor(private plugin: RosyPilot) {} + + async run(prefix: string, editor: Editor): Promise { + const yuandian = this.getYuandianClient(); + if (!yuandian) { + new Notice(t('legal.notice.noApiKey')); + return; + } + + const editorView = (editor as unknown as { cm: EditorView }).cm; + const context = prefix.slice(-50); + + const view = await this.plugin.openLegalPanel(); + view.setLoading(t('legal.panel.detecting')); + + const ref = await this.detectLegalRef(context); + + if (!ref) { + view.setError(t('legal.panel.empty')); + return; + } + + view.setLoading(t('legal.panel.fetching')); + + try { + const article = await yuandian.fetchDetail(ref.fgmc, ref.ftnum); + if (article) { + view.setDetail(article, { + onRaw: () => { + this.injectGhostText( + editorView, + `${article.ftmc}\n${article.content}`, + ); + }, + onAdapted: () => this.adaptAndInsert(editorView, prefix, article), + }); + } else { + view.setError(t('legal.panel.empty')); + } + } catch { + view.setError(t('legal.panel.error')); + } + } + + private injectGhostText(editorView: EditorView, text: string): void { + editorView.dispatch({ + effects: [setCompletionsEffect.of({ completions: text })], + }); + } + + private async adaptAndInsert( + editorView: EditorView, + prefix: string, + article: ArticleDetail, + ): Promise { + const { settings } = this.plugin; + const provider = settings.completions.provider; + const apiKey = settings.providers[provider].apiKey; + const model = settings.completions.model; + + if (!apiKey || !model) return; + + const userMessage = `【当前文档光标前文本】\n${prefix.slice(-300)}\n\n【相关法条】\n${article.ftmc}\n${article.content}`; + + const baseURL = PROVIDERS_BASE_URLS[provider]; + const res = await requestUrl({ + url: `${baseURL}/chat/completions`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: ADAPT_SYSTEM_PROMPT }, + { role: 'user', content: userMessage }, + ], + max_tokens: 4096, + temperature: 0.3, + }), + throw: false, + }); + + if (res.status !== 200 && res.status !== 201) return; + + const body = res.json as { + choices?: { message?: { content?: string | null } }[]; + }; + const adapted = body?.choices?.[0]?.message?.content?.trim() ?? ''; + + if (adapted) { + this.injectGhostText(editorView, adapted); + } + } + + private async detectLegalRef(text: string): Promise { + const { settings } = this.plugin; + const provider = settings.completions.provider; + const apiKey = settings.providers[provider].apiKey; + const model = settings.completions.model; + + if (!apiKey || !model) return null; + + const baseURL = PROVIDERS_BASE_URLS[provider]; + const res = await requestUrl({ + url: `${baseURL}/chat/completions`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages: [ + { role: 'system', content: EXTRACT_SYSTEM_PROMPT }, + { role: 'user', content: text }, + ], + max_tokens: 1024, + temperature: 0, + }), + throw: false, + }); + + if (res.status !== 200 && res.status !== 201) return null; + + const body = res.json as { + choices?: { + message?: { content?: string | null; reasoning_content?: string }; + }[]; + }; + const msg = body?.choices?.[0]?.message; + const content = + (msg?.content?.trim() || msg?.reasoning_content?.trim()) ?? ''; + if (!content || content === 'null') return null; + + try { + const ref = JSON.parse(content) as LegalRef; + if (typeof ref?.fgmc === 'string' && typeof ref?.ftnum === 'string') { + return ref; + } + } catch { + // malformed JSON → no match + } + return null; + } + + private getYuandianClient(): YuandianClient | null { + const key = this.plugin.settings.legal?.yuandianApiKey; + if (!key) return null; + return new YuandianClient(key); + } +} diff --git a/src/legal/view.ts b/src/legal/view.ts new file mode 100644 index 0000000..a61c230 --- /dev/null +++ b/src/legal/view.ts @@ -0,0 +1,135 @@ +import { ItemView, WorkspaceLeaf } from 'obsidian'; +import { t } from 'src/i18n'; +import { ArticleDetail, ArticleSearchItem } from './yuandian-client'; + +export const LEGAL_PANEL_VIEW_TYPE = 'rosypilot-legal-panel'; + +export class LegalPanelView extends ItemView { + private container!: HTMLElement; + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + } + + getViewType(): string { + return LEGAL_PANEL_VIEW_TYPE; + } + + getDisplayText(): string { + return t('legal.panel.title'); + } + + getIcon(): string { + return 'scale'; + } + + onOpen(): Promise { + this.container = this.contentEl.createDiv('rosypilot-legal-panel'); + this.container + .createDiv('rosypilot-legal-empty') + .setText(t('legal.panel.empty')); + return Promise.resolve(); + } + + onClose(): Promise { + return Promise.resolve(); + } + + setLoading(msg?: string): void { + this.container.empty(); + this.container + .createDiv('rosypilot-legal-status') + .setText(msg ?? t('legal.panel.loading')); + } + + setError(msg: string): void { + this.container.empty(); + this.container.createDiv('rosypilot-legal-status').setText(msg); + } + + setDetail( + article: ArticleDetail, + callbacks?: { onRaw: () => void; onAdapted: () => Promise }, + ): void { + this.container.empty(); + + const label = this.container.createDiv('rosypilot-legal-label'); + label.setText(t('legal.panel.detail.label')); + + this.renderItem( + this.container, + { + title: article.ftmc, + content: article.content, + meta: [article.fgmc, article.sxx, article.xljb_1] + .filter(Boolean) + .join(' · '), + }, + callbacks, + ); + } + + setSearchResults(items: ArticleSearchItem[]): void { + this.container.empty(); + + if (items.length === 0) { + this.container + .createDiv('rosypilot-legal-status') + .setText(t('legal.panel.empty')); + return; + } + + const label = this.container.createDiv('rosypilot-legal-label'); + label.setText(t('legal.panel.search.label')); + + for (const item of items) { + const fgtitle = Array.isArray(item.fgtitle) + ? item.fgtitle.join('') + : item.fgtitle ?? ''; + const title = fgtitle ? fgtitle + item.num : item.num; + this.renderItem(this.container, { + title, + content: item.content, + meta: [item.effect1, item.sxx].filter(Boolean).join(' · '), + }); + } + } + + private renderItem( + parent: HTMLElement, + { title, content, meta }: { title: string; content: string; meta: string }, + callbacks?: { onRaw: () => void; onAdapted: () => Promise }, + ): void { + const card = parent.createDiv('rosypilot-legal-item'); + card.createDiv('rosypilot-legal-item-title').setText(title); + card.createDiv('rosypilot-legal-item-content').setText(content); + if (meta) { + card.createDiv('rosypilot-legal-item-meta').setText(meta); + } + if (callbacks) { + const actions = card.createDiv('rosypilot-legal-item-actions'); + + const rawBtn = actions.createEl('button', { + cls: 'rosypilot-legal-btn', + text: t('legal.panel.insert.raw'), + }); + rawBtn.addEventListener('click', callbacks.onRaw); + + const adaptedBtn = actions.createEl('button', { + cls: 'rosypilot-legal-btn', + text: t('legal.panel.insert.adapted'), + }); + adaptedBtn.addEventListener('click', () => { + if (adaptedBtn.disabled) return; + adaptedBtn.disabled = true; + adaptedBtn.empty(); + adaptedBtn.createSpan({ cls: 'rosypilot-legal-btn-spinner' }); + void callbacks.onAdapted().finally(() => { + adaptedBtn.disabled = false; + adaptedBtn.empty(); + adaptedBtn.setText(t('legal.panel.insert.adapted')); + }); + }); + } + } +} diff --git a/src/legal/yuandian-client.ts b/src/legal/yuandian-client.ts new file mode 100644 index 0000000..0567c14 --- /dev/null +++ b/src/legal/yuandian-client.ts @@ -0,0 +1,97 @@ +import { requestUrl } from 'obsidian'; + +const BASE_URL = 'https://open.chineselaw.com'; + +export interface ArticleDetail { + id: string; + fgmc: string; + ft_num: string; + ftmc: string; + content: string; + sxx: string; + xljb_1: string; + fbrq: string; + ssrq: string; +} + +export interface ArticleSearchItem { + ftid: string; + fgid: string; + fgtitle: string[]; + num: string; + content: string; + sxx: string; + effect1: string; + score: number; +} + +export class YuandianClient { + constructor(private apiKey: string) {} + + async fetchDetail( + fgmc: string, + ftnum: string, + ): Promise { + const res = await requestUrl({ + url: `${BASE_URL}/open/rh_ft_detail`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': this.apiKey, + }, + body: JSON.stringify({ fgmc, ftnum }), + throw: false, + }); + + if (res.status !== 200 && res.status !== 201) { + throw new Error(`HTTP ${res.status}`); + } + + const body = res.json as { + status: string; + code: number; + message: string; + data: ArticleDetail | null; + }; + + if (body.code !== 200 && body.code !== 201) { + throw new Error(body.message); + } + + return body.data; + } + + async searchArticles(query: string): Promise { + const res = await requestUrl({ + url: `${BASE_URL}/open/law_vector_search`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': this.apiKey, + }, + body: JSON.stringify({ + query, + rewrite_flag: true, + fatiao_filter: { sxx: ['现行有效'] }, + return_num: 5, + }), + throw: false, + }); + + if (res.status !== 200 && res.status !== 201) { + throw new Error(`HTTP ${res.status}`); + } + + const body = res.json as { + msg: string; + code: number; + extra: { fatiao: ArticleSearchItem[] }; + }; + + if (body.code !== 200 && body.code !== 201) { + throw new Error(body.msg); + } + + return body.extra?.fatiao ?? []; + } +} diff --git a/src/main.ts b/src/main.ts index d331ced..a9d9df3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,8 @@ import { MemoryCacheProxy } from './api/proxies/memory-cache'; import { UsageMonitorProxy } from './api/proxies/usage-monitor'; import { DEBUG_VIEW_TYPE, DebugEntry, DebugView } from './debug/view'; import { inlineCompletionsExtension } from './editor/extension'; +import { LegalSlashCommand } from './legal/slash-command'; +import { LEGAL_PANEL_VIEW_TYPE, LegalPanelView } from './legal/view'; import { t } from './i18n'; import flowerOffIcon from './icons/flower-off.svg'; import { @@ -25,6 +27,7 @@ export default class RosyPilot extends Plugin { extensions!: Extension[]; completionsClient!: APIClient; debugView: DebugView | null = null; + legalPanelView: LegalPanelView | null = null; async onload() { await this.loadSettings(); @@ -45,6 +48,12 @@ export default class RosyPilot extends Plugin { return view; }); + this.registerView(LEGAL_PANEL_VIEW_TYPE, (leaf) => { + const view = new LegalPanelView(leaf); + this.legalPanelView = view; + return view; + }); + this.registerCustomIcons(); // Must be called before `registerRibbonActions()`. this.registerRibbonActions(); this.registerCommands(); @@ -78,75 +87,27 @@ export default class RosyPilot extends Plugin { registerCommands() { this.addCommand({ - id: 'enable-completions', - name: t('command.enableCompletions'), - callback: async () => { - this.settings.completions.enabled = true; - await this.saveSettings(); - new Notice(t('notice.completions.enabled')); - }, - }); - - this.addCommand({ - id: 'disable-completions', - name: t('command.disableCompletions'), - callback: async () => { - this.settings.completions.enabled = false; - await this.saveSettings(); - new Notice(t('notice.completions.disabled')); - }, - }); - - this.addCommand({ - id: 'toggle-completions', - name: t('command.toggleCompletions'), - callback: async () => { - this.settings.completions.enabled = !this.settings.completions.enabled; - await this.saveSettings(); - new Notice( - this.settings.completions.enabled - ? t('notice.completions.enabled') - : t('notice.completions.disabled'), - ); - }, - }); - - this.addCommand({ - id: 'enable-cache', - name: t('command.enableCache'), - callback: async () => { - this.settings.cache.enabled = true; - await this.saveSettings(); - new Notice(t('notice.cache.enabled')); - }, - }); - - this.addCommand({ - id: 'disable-cache', - name: t('command.disableCache'), - callback: async () => { - this.settings.cache.enabled = false; - await this.saveSettings(); - new Notice(t('notice.cache.disabled')); - }, - }); - - this.addCommand({ - id: 'toggle-cache', - name: t('command.toggleCache'), - callback: async () => { - this.settings.cache.enabled = !this.settings.cache.enabled; - await this.saveSettings(); - new Notice( - this.settings.cache.enabled - ? t('notice.cache.enabled') - : t('notice.cache.disabled'), - ); + id: 'complete-legal-provision', + name: t('legal.slashCommand.label'), + editorCallback: (editor) => { + // Remove the trailing space that was typed before "/" to trigger + // Obsidian's slash command popup in non-empty lines. + const cursor = editor.getCursor(); + const lineText = editor.getLine(cursor.line); + if (cursor.ch > 0 && lineText[cursor.ch - 1] === ' ') { + editor.replaceRange( + '', + { line: cursor.line, ch: cursor.ch - 1 }, + cursor, + ); + } + const prefix = editor.getRange({ line: 0, ch: 0 }, editor.getCursor()); + void new LegalSlashCommand(this).run(prefix, editor); }, }); } - createAPIClient(provider: Provider) { + createAPIClient(_provider: Provider) { const generator = new PromptGenerator(this); const tracker = new TokenTracker(this); const client = new OpenAICompatibleAPIClient(generator, tracker, this); @@ -217,6 +178,20 @@ export default class RosyPilot extends Plugin { await leaf?.setViewState({ type: DEBUG_VIEW_TYPE, active: true }); } + async openLegalPanel(): Promise { + const { workspace } = this.app; + + const leaves = workspace.getLeavesOfType(LEGAL_PANEL_VIEW_TYPE); + if (leaves.length > 0) { + await workspace.revealLeaf(leaves[0]); + return leaves[0].view as LegalPanelView; + } + + const leaf = workspace.getRightLeaf(false); + await leaf?.setViewState({ type: LEGAL_PANEL_VIEW_TYPE, active: true }); + return this.legalPanelView!; + } + deactivateDebugView() { const { workspace } = this.app; @@ -272,6 +247,11 @@ export default class RosyPilot extends Plugin { DEFAULT_SETTINGS.usage, this.settings.usage, ); + this.settings.legal = Object.assign( + {}, + DEFAULT_SETTINGS.legal, + this.settings.legal, + ); } async saveSettings() { diff --git a/src/settings/index.ts b/src/settings/index.ts index 93ce0c2..ad6b920 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -47,6 +47,9 @@ export interface RosyPilotSettings { monthlyTokens: Record; monthlyLimit: number; }; + legal: { + yuandianApiKey: string | undefined; + }; } const defaultProviders: Record< @@ -58,7 +61,7 @@ for (const provider of PROVIDERS) { } export const DEFAULT_SETTINGS: RosyPilotSettings = { - version: '2.3.0', + version: '2.4.0', backups: {}, providers: defaultProviders, completions: { @@ -83,6 +86,9 @@ export const DEFAULT_SETTINGS: RosyPilotSettings = { monthlyTokens: {}, monthlyLimit: 10_000_000, }, + legal: { + yuandianApiKey: undefined, + }, }; export class RosyPilotSettingTab extends PluginSettingTab { @@ -414,6 +420,24 @@ export class RosyPilotSettingTab extends PluginSettingTab { this.showMonthlyTokens(); + /************************************************************/ + /* Legal database */ + /************************************************************/ + + new Setting(containerEl).setName(t('settings.legal.title')).setHeading(); + + new Setting(containerEl) + .setName(t('settings.legal.apiKey')) + .setDesc(t('settings.legal.apiKeyDesc')) + .addText((text) => + text + .setValue(settings.legal?.yuandianApiKey ?? '') + .onChange(async (value) => { + settings.legal.yuandianApiKey = value || undefined; + await plugin.saveSettings(); + }), + ); + /************************************************************/ /* About */ /************************************************************/ diff --git a/src/settings/migrators/2.3.0-2.4.0.ts b/src/settings/migrators/2.3.0-2.4.0.ts new file mode 100644 index 0000000..93e1cd4 --- /dev/null +++ b/src/settings/migrators/2.3.0-2.4.0.ts @@ -0,0 +1,26 @@ +import { SettingsMigrator } from '.'; +import { RosyPilotSettings2_3_0 } from '../versions/2.3.0'; +import { RosyPilotSettings2_4_0 } from '../versions/2.4.0'; + +export const migrateVersion2_3_0_toVersion2_4_0: SettingsMigrator< + RosyPilotSettings2_3_0, + RosyPilotSettings2_4_0 +> = (settings) => { + const backup = structuredClone(settings); + + return { + version: '2.4.0', + backups: { + ...settings.backups, + '2.3.0': backup, + }, + providers: settings.providers, + completions: settings.completions, + cache: settings.cache, + debug: settings.debug, + usage: settings.usage, + legal: { + yuandianApiKey: undefined, + }, + }; +}; diff --git a/src/settings/runner.ts b/src/settings/runner.ts index 320b6f3..1e783f8 100644 --- a/src/settings/runner.ts +++ b/src/settings/runner.ts @@ -6,6 +6,7 @@ import { migrateVersion1_2_5_toVersion2_0_0 } from './migrators/1.2.5-2.0.0'; import { migrateVersion2_0_0_toVersion2_1_0 } from './migrators/2.0.0-2.1.0'; import { migrateVersion2_1_0_toVersion2_2_0 } from './migrators/2.1.0-2.2.0'; import { migrateVersion2_2_0_toVersion2_3_0 } from './migrators/2.2.0-2.3.0'; +import { migrateVersion2_3_0_toVersion2_4_0 } from './migrators/2.3.0-2.4.0'; import { RosyPilotSettings } from '.'; // Minimal shape shared by all versioned settings objects. @@ -23,6 +24,7 @@ export class SettingsMigrationsRunner { '2.0.0': migrateVersion2_0_0_toVersion2_1_0 as unknown as AnyMigrator, '2.1.0': migrateVersion2_1_0_toVersion2_2_0 as unknown as AnyMigrator, '2.2.0': migrateVersion2_2_0_toVersion2_3_0 as unknown as AnyMigrator, + '2.3.0': migrateVersion2_3_0_toVersion2_4_0 as unknown as AnyMigrator, }; constructor(private plugin: RosyPilot) {} diff --git a/src/settings/versions/2.3.0/index.ts b/src/settings/versions/2.3.0/index.ts index d859f77..c72e224 100644 --- a/src/settings/versions/2.3.0/index.ts +++ b/src/settings/versions/2.3.0/index.ts @@ -1,7 +1,3 @@ -// Check the settings type in this version matches the current settings type. - -import { RosyPilotSettings } from 'src/settings'; -import { Equal, Expect } from 'src/settings/utils'; import { Provider } from '../../../api/providers'; export interface RosyPilotSettings2_3_0 { @@ -37,8 +33,3 @@ export interface RosyPilotSettings2_3_0 { monthlyLimit: number; }; } - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -- compile-time assertion type is intentionally unused at runtime -type AssertEqualCurrentSettings = Expect< - Equal ->; diff --git a/src/settings/versions/2.4.0/index.ts b/src/settings/versions/2.4.0/index.ts new file mode 100644 index 0000000..f4e79d0 --- /dev/null +++ b/src/settings/versions/2.4.0/index.ts @@ -0,0 +1,47 @@ +// Check the settings type in this version matches the current settings type. + +import { RosyPilotSettings } from 'src/settings'; +import { Equal, Expect } from 'src/settings/utils'; +import { Provider } from '../../../api/providers'; + +export interface RosyPilotSettings2_4_0 { + version: string; + backups: Record; + providers: Record< + Provider, + { + apiKey: string | undefined; + fetchedModels: string[]; + } + >; + completions: { + enabled: boolean; + provider: Provider; + model: string; + maxTokens: number; + temperature: number; + waitTime: number; + windowSize: number; + acceptKey: string; + rejectKey: string; + }; + cache: { + enabled: boolean; + }; + debug: { + enabled: boolean; + }; + usage: { + dailyTokens: Record; + monthlyTokens: Record; + monthlyLimit: number; + }; + legal: { + yuandianApiKey: string | undefined; + }; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- compile-time assertion type is intentionally unused at runtime +type AssertEqualCurrentSettings = Expect< + Equal +>; diff --git a/styles.css b/styles.css index a0274a9..e7eb884 100644 --- a/styles.css +++ b/styles.css @@ -239,3 +239,91 @@ .rosypilot-hidden { display: none; } + +/************************************************************/ +/* Legal panel */ +/************************************************************/ + +.rosypilot-legal-panel { + padding: 8px; + overflow-y: auto; + height: 100%; + box-sizing: border-box; +} + +.rosypilot-legal-status { + padding: 12px 4px; + font-size: 0.85em; + opacity: 0.6; +} + +.rosypilot-legal-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.5; + margin-bottom: 6px; + padding: 0 2px; +} + +.rosypilot-legal-item { + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 10px 12px; + margin-bottom: 8px; + background: var(--background-secondary); +} + +.rosypilot-legal-item-title { + font-weight: 600; + font-size: 0.9em; + margin-bottom: 6px; + line-height: 1.4; +} + +.rosypilot-legal-item-content { + font-size: 0.85em; + line-height: 1.6; + white-space: pre-wrap; + margin-bottom: 6px; +} + +.rosypilot-legal-item-meta { + font-size: 0.75em; + opacity: 0.55; +} + +.rosypilot-legal-item-actions { + display: flex; + gap: 6px; + margin-top: 8px; +} + +.rosypilot-legal-btn { + font-size: 0.8em; + padding: 2px 10px; + border-radius: 4px; + border: 1px solid var(--interactive-accent); + color: var(--interactive-accent); + background: transparent; + cursor: pointer; +} + +.rosypilot-legal-btn:hover { + background: var(--interactive-accent); + color: var(--text-on-accent); +} + +.rosypilot-legal-btn-spinner { + display: inline-block; + width: 11px; + height: 11px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: rosypilot-spin 0.6s linear infinite; +} + +@keyframes rosypilot-spin { + to { transform: rotate(360deg); } +}