mirror of
https://github.com/zh-xx/rosypilot.git
synced 2026-07-22 06:06:43 +00:00
feat: add 补全法条 command with 元典 API integration
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
c523c69712
commit
79e06a913e
14 changed files with 689 additions and 75 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,6 +21,9 @@ data.json
|
|||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Internal development files
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
|
|
|||
|
|
@ -102,4 +102,28 @@ export const en: Record<string, string> = {
|
|||
|
||||
// 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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -97,4 +97,27 @@ export const zh: Record<string, string> = {
|
|||
|
||||
// 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',
|
||||
};
|
||||
|
|
|
|||
4
src/legal/detector.ts
Normal file
4
src/legal/detector.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export interface LegalRef {
|
||||
fgmc: string;
|
||||
ftnum: string;
|
||||
}
|
||||
170
src/legal/slash-command.ts
Normal file
170
src/legal/slash-command.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<LegalRef | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
135
src/legal/view.ts
Normal file
135
src/legal/view.ts
Normal file
|
|
@ -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<void> {
|
||||
this.container = this.contentEl.createDiv('rosypilot-legal-panel');
|
||||
this.container
|
||||
.createDiv('rosypilot-legal-empty')
|
||||
.setText(t('legal.panel.empty'));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
onClose(): Promise<void> {
|
||||
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> },
|
||||
): 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> },
|
||||
): 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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/legal/yuandian-client.ts
Normal file
97
src/legal/yuandian-client.ts
Normal file
|
|
@ -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<ArticleDetail | null> {
|
||||
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<ArticleSearchItem[]> {
|
||||
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 ?? [];
|
||||
}
|
||||
}
|
||||
110
src/main.ts
110
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<LegalPanelView> {
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ export interface RosyPilotSettings {
|
|||
monthlyTokens: Record<string, number>;
|
||||
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 */
|
||||
/************************************************************/
|
||||
|
|
|
|||
26
src/settings/migrators/2.3.0-2.4.0.ts
Normal file
26
src/settings/migrators/2.3.0-2.4.0.ts
Normal file
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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<RosyPilotSettings2_3_0, RosyPilotSettings>
|
||||
>;
|
||||
|
|
|
|||
47
src/settings/versions/2.4.0/index.ts
Normal file
47
src/settings/versions/2.4.0/index.ts
Normal file
|
|
@ -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<string, object>;
|
||||
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<string, number>;
|
||||
monthlyTokens: Record<string, number>;
|
||||
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<RosyPilotSettings2_4_0, RosyPilotSettings>
|
||||
>;
|
||||
88
styles.css
88
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); }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue