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:
Jee C 2026-05-09 21:43:23 +08:00
parent c523c69712
commit 79e06a913e
14 changed files with 689 additions and 75 deletions

3
.gitignore vendored
View file

@ -21,6 +21,9 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# Secrets
.env
# Internal development files
CLAUDE.md
AGENTS.md

View file

@ -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.',
};

View file

@ -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
View file

@ -0,0 +1,4 @@
export interface LegalRef {
fgmc: string;
ftnum: string;
}

170
src/legal/slash-command.ts Normal file
View 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
View 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'));
});
});
}
}
}

View 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 ?? [];
}
}

View file

@ -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() {

View file

@ -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 */
/************************************************************/

View 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,
},
};
};

View file

@ -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) {}

View file

@ -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>
>;

View 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>
>;

View file

@ -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); }
}